feat: 搭建后端多租户名片服务

- 初始化 Spring Boot 多模块工程与通用基础能力

- 增加租户、组织、用户、名片、文件、统计等业务模块

- 补充数据库迁移脚本与基础测试
This commit is contained in:
2026-03-20 12:43:21 +08:00
parent 1a2a078c0f
commit 9ef50288e9
95 changed files with 6722 additions and 0 deletions

View File

@@ -0,0 +1,35 @@
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.easycard</groupId>
<artifactId>easycard-parent</artifactId>
<version>0.1.0-SNAPSHOT</version>
</parent>
<artifactId>easycard-module-user</artifactId>
<name>easycard-module-user</name>
<dependencies>
<dependency>
<groupId>com.easycard</groupId>
<artifactId>easycard-common</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>com.easycard</groupId>
<artifactId>easycard-module-org</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
</dependencies>
</project>

View File

@@ -0,0 +1,63 @@
package com.easycard.module.user.controller;
import com.easycard.common.api.ApiResponse;
import com.easycard.common.auth.SecurityUtils;
import com.easycard.common.web.ClientRequestUtils;
import com.easycard.module.user.service.AuthService;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/v1/auth")
public class AuthController {
private final AuthService authService;
public AuthController(AuthService authService) {
this.authService = authService;
}
@PostMapping("/login")
public ApiResponse<AuthService.LoginResult> login(@Valid @RequestBody LoginRequest request, HttpServletRequest servletRequest) {
return ApiResponse.success(authService.login(
request.username(),
request.password(),
ClientRequestUtils.getClientIp(servletRequest),
servletRequest.getHeader("User-Agent")
));
}
@PostMapping("/logout")
public ApiResponse<Void> logout() {
return ApiResponse.success("退出成功", null);
}
@GetMapping("/me")
public ApiResponse<AuthService.CurrentUserView> me() {
return ApiResponse.success(authService.getCurrentUser());
}
@PostMapping("/change-password")
public ApiResponse<Void> changePassword(@Valid @RequestBody ChangePasswordRequest request) {
authService.changePassword(request.oldPassword(), request.newPassword());
return ApiResponse.success("修改成功", null);
}
}
record LoginRequest(
@NotBlank(message = "账号不能为空") String username,
@NotBlank(message = "密码不能为空") String password
) {
}
record ChangePasswordRequest(
@NotBlank(message = "原密码不能为空") String oldPassword,
@NotBlank(message = "新密码不能为空") String newPassword
) {
}

View File

@@ -0,0 +1,110 @@
package com.easycard.module.user.controller;
import com.easycard.common.api.ApiResponse;
import com.easycard.module.user.service.TenantUserService;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
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.RestController;
import java.util.List;
@RestController
@RequestMapping("/api/v1/tenant/users")
public class TenantUserController {
private final TenantUserService tenantUserService;
public TenantUserController(TenantUserService tenantUserService) {
this.tenantUserService = tenantUserService;
}
@GetMapping
@PreAuthorize("hasAnyAuthority('TENANT_ADMIN','PLATFORM_SUPER_ADMIN')")
public ApiResponse<List<TenantUserService.TenantUserView>> listUsers(@RequestParam(required = false) String keyword) {
return ApiResponse.success(tenantUserService.listUsers(keyword));
}
@PostMapping
@PreAuthorize("hasAnyAuthority('TENANT_ADMIN','PLATFORM_SUPER_ADMIN')")
public ApiResponse<TenantUserService.TenantUserView> createUser(@Valid @RequestBody UpsertTenantUserRequest request) {
return ApiResponse.success(tenantUserService.createUser(request.toServiceRequest()));
}
@PutMapping("/{userId}")
@PreAuthorize("hasAnyAuthority('TENANT_ADMIN','PLATFORM_SUPER_ADMIN')")
public ApiResponse<TenantUserService.TenantUserView> updateUser(@PathVariable Long userId, @Valid @RequestBody UpsertTenantUserRequest request) {
return ApiResponse.success(tenantUserService.updateUser(userId, request.toServiceRequest()));
}
@PatchMapping("/{userId}/status")
@PreAuthorize("hasAnyAuthority('TENANT_ADMIN','PLATFORM_SUPER_ADMIN')")
public ApiResponse<TenantUserService.TenantUserView> updateUserStatus(@PathVariable Long userId, @Valid @RequestBody UpdateTenantUserStatusRequest request) {
return ApiResponse.success(tenantUserService.updateUserStatus(userId, request.userStatus()));
}
@PutMapping("/sort")
@PreAuthorize("hasAnyAuthority('TENANT_ADMIN','PLATFORM_SUPER_ADMIN')")
public ApiResponse<Void> sortUsers(@Valid @RequestBody SortTenantUsersRequest request) {
tenantUserService.sortUsers(request.userIds());
return ApiResponse.success("排序已生效", null);
}
@PostMapping("/{userId}/reset-password")
@PreAuthorize("hasAnyAuthority('TENANT_ADMIN','PLATFORM_SUPER_ADMIN')")
public ApiResponse<Void> resetPassword(@PathVariable Long userId) {
tenantUserService.resetPassword(userId);
return ApiResponse.success("重置成功", null);
}
@GetMapping("/{userId}")
@PreAuthorize("hasAnyAuthority('TENANT_ADMIN','TENANT_USER','PLATFORM_SUPER_ADMIN')")
public ApiResponse<TenantUserService.TenantUserView> getUser(@PathVariable Long userId) {
return ApiResponse.success(tenantUserService.getUser(userId));
}
}
record UpsertTenantUserRequest(
@NotBlank(message = "账号不能为空") String username,
@NotBlank(message = "姓名不能为空") String realName,
String mobile,
@Email(message = "邮箱格式错误") String email,
Long deptId,
String jobTitle,
@NotBlank(message = "状态不能为空") String userStatus,
@NotBlank(message = "角色不能为空") String roleCode
) {
TenantUserService.CreateOrUpdateUserRequest toServiceRequest() {
return new TenantUserService.CreateOrUpdateUserRequest(
username,
realName,
mobile,
email,
deptId,
jobTitle,
userStatus,
roleCode
);
}
}
record UpdateTenantUserStatusRequest(
@NotBlank(message = "状态不能为空") String userStatus
) {
}
record SortTenantUsersRequest(
@Size(min = 1, message = "排序成员不能为空") List<@NotNull(message = "成员ID不能为空") Long> userIds
) {
}

View File

@@ -0,0 +1,25 @@
package com.easycard.module.user.dal.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("sys_login_log")
public class SysLoginLogDO {
@TableId(type = IdType.AUTO)
private Long id;
private Long tenantId;
private Long userId;
private String userType;
private String loginType;
private String loginStatus;
private String clientIp;
private String userAgent;
private String failReason;
private LocalDateTime loginAt;
}

View File

@@ -0,0 +1,30 @@
package com.easycard.module.user.dal.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("sys_operation_log")
public class SysOperationLogDO {
@TableId(type = IdType.AUTO)
private Long id;
private Long tenantId;
private Long userId;
private String userName;
private String moduleName;
private String bizType;
private Long bizId;
private String operationType;
private String requestMethod;
private String requestUri;
private String requestBody;
private String responseCode;
private String responseMessage;
private String clientIp;
private LocalDateTime operatedAt;
}

View File

@@ -0,0 +1,31 @@
package com.easycard.module.user.dal.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableLogic;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("sys_role")
public class SysRoleDO {
@TableId(type = IdType.AUTO)
private Long id;
private Long tenantId;
private String roleScope;
private String roleCode;
private String roleName;
private String dataScope;
private Integer isBuiltin;
private String roleStatus;
private String remark;
private Long createdBy;
private LocalDateTime createdTime;
private Long updatedBy;
private LocalDateTime updatedTime;
@TableLogic
private Integer deleted;
}

View File

@@ -0,0 +1,41 @@
package com.easycard.module.user.dal.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableLogic;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("sys_user")
public class SysUserDO {
@TableId(type = IdType.AUTO)
private Long id;
private Long tenantId;
private String userType;
private String username;
private String passwordHash;
private String realName;
private String nickName;
private String gender;
private String mobile;
private String email;
private Long avatarAssetId;
private Long deptId;
private String jobTitle;
private String userStatus;
private Integer memberSort;
private Integer mustUpdatePassword;
private LocalDateTime lastLoginAt;
private String lastLoginIp;
private String remark;
private Long createdBy;
private LocalDateTime createdTime;
private Long updatedBy;
private LocalDateTime updatedTime;
@TableLogic
private Integer deleted;
}

View File

@@ -0,0 +1,21 @@
package com.easycard.module.user.dal.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("sys_user_role")
public class SysUserRoleDO {
@TableId(type = IdType.AUTO)
private Long id;
private Long tenantId;
private Long userId;
private Long roleId;
private Long createdBy;
private LocalDateTime createdTime;
}

View File

@@ -0,0 +1,7 @@
package com.easycard.module.user.dal.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.easycard.module.user.dal.entity.SysLoginLogDO;
public interface SysLoginLogMapper extends BaseMapper<SysLoginLogDO> {
}

View File

@@ -0,0 +1,7 @@
package com.easycard.module.user.dal.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.easycard.module.user.dal.entity.SysOperationLogDO;
public interface SysOperationLogMapper extends BaseMapper<SysOperationLogDO> {
}

View File

@@ -0,0 +1,7 @@
package com.easycard.module.user.dal.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.easycard.module.user.dal.entity.SysRoleDO;
public interface SysRoleMapper extends BaseMapper<SysRoleDO> {
}

View File

@@ -0,0 +1,7 @@
package com.easycard.module.user.dal.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.easycard.module.user.dal.entity.SysUserDO;
public interface SysUserMapper extends BaseMapper<SysUserDO> {
}

View File

@@ -0,0 +1,7 @@
package com.easycard.module.user.dal.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.easycard.module.user.dal.entity.SysUserRoleDO;
public interface SysUserRoleMapper extends BaseMapper<SysUserRoleDO> {
}

View File

@@ -0,0 +1,186 @@
package com.easycard.module.user.service;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.easycard.common.auth.JwtTokenService;
import com.easycard.common.auth.LoginUser;
import com.easycard.common.auth.SecurityUtils;
import com.easycard.common.exception.BusinessException;
import com.easycard.module.org.dal.entity.OrgDepartmentDO;
import com.easycard.module.org.dal.mapper.OrgDepartmentMapper;
import com.easycard.module.user.dal.entity.SysRoleDO;
import com.easycard.module.user.dal.entity.SysUserDO;
import com.easycard.module.user.dal.entity.SysUserRoleDO;
import com.easycard.module.user.dal.mapper.SysRoleMapper;
import com.easycard.module.user.dal.mapper.SysUserMapper;
import com.easycard.module.user.dal.mapper.SysUserRoleMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.util.StringUtils;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
@Service
@RequiredArgsConstructor
public class AuthService {
private final SysUserMapper sysUserMapper;
private final SysRoleMapper sysRoleMapper;
private final SysUserRoleMapper sysUserRoleMapper;
private final OrgDepartmentMapper orgDepartmentMapper;
private final JdbcTemplate jdbcTemplate;
private final PasswordEncoder passwordEncoder;
private final JwtTokenService jwtTokenService;
private final UserAuditService userAuditService;
@Value("${easycard.security.jwt-expire-hours:24}")
private long jwtExpireHours;
@Transactional
public LoginResult login(String username, String password, String clientIp, String userAgent) {
SysUserDO user = sysUserMapper.selectOne(Wrappers.<SysUserDO>lambdaQuery()
.eq(SysUserDO::getUsername, username)
.eq(SysUserDO::getDeleted, 0)
.last("LIMIT 1"));
if (user == null) {
userAuditService.recordLogin(0L, null, "UNKNOWN", "FAIL", clientIp, userAgent, "账号不存在");
throw new BusinessException("AUTH_FAILED", "账号或密码错误");
}
if (!"ENABLED".equals(user.getUserStatus())) {
userAuditService.recordLogin(user.getTenantId(), user.getId(), user.getUserType(), "FAIL", clientIp, userAgent, "账号已停用");
throw new BusinessException("AUTH_FAILED", "账号已停用");
}
if (!passwordEncoder.matches(password, user.getPasswordHash())) {
userAuditService.recordLogin(user.getTenantId(), user.getId(), user.getUserType(), "FAIL", clientIp, userAgent, "密码错误");
throw new BusinessException("AUTH_FAILED", "账号或密码错误");
}
List<String> roleCodes = getRoleCodes(user.getId(), user.getTenantId());
LoginUser loginUser = new LoginUser(
user.getId(),
user.getTenantId(),
user.getUsername(),
user.getRealName(),
user.getUserType(),
roleCodes
);
user.setLastLoginAt(LocalDateTime.now());
user.setLastLoginIp(clientIp);
user.setUpdatedBy(user.getId());
sysUserMapper.updateById(user);
userAuditService.recordLogin(user.getTenantId(), user.getId(), user.getUserType(), "SUCCESS", clientIp, userAgent, null);
return new LoginResult(jwtTokenService.generateToken(loginUser, Duration.ofHours(jwtExpireHours)), buildCurrentUser(user, roleCodes));
}
public CurrentUserView getCurrentUser() {
LoginUser loginUser = SecurityUtils.getRequiredLoginUser();
SysUserDO user = getRequiredUser(loginUser.userId());
return buildCurrentUser(user, loginUser.roleCodes());
}
@Transactional
public void changePassword(String oldPassword, String newPassword) {
LoginUser loginUser = SecurityUtils.getRequiredLoginUser();
SysUserDO user = getRequiredUser(loginUser.userId());
if (!passwordEncoder.matches(oldPassword, user.getPasswordHash())) {
throw new BusinessException("PASSWORD_INVALID", "原密码错误");
}
user.setPasswordHash(passwordEncoder.encode(newPassword));
user.setMustUpdatePassword(0);
user.setUpdatedBy(user.getId());
sysUserMapper.updateById(user);
userAuditService.recordOperation(user.getTenantId(), user.getId(), user.getRealName(), "AUTH", "PASSWORD", user.getId(), "CHANGE_PASSWORD");
}
public SysUserDO getRequiredUser(Long userId) {
SysUserDO user = sysUserMapper.selectById(userId);
if (user == null || Integer.valueOf(1).equals(user.getDeleted())) {
throw new BusinessException("USER_NOT_FOUND", "用户不存在");
}
return user;
}
public List<String> getRoleCodes(Long userId, Long tenantId) {
List<SysUserRoleDO> userRoles = sysUserRoleMapper.selectList(Wrappers.<SysUserRoleDO>lambdaQuery()
.eq(SysUserRoleDO::getUserId, userId)
.eq(SysUserRoleDO::getTenantId, tenantId));
if (userRoles.isEmpty()) {
return List.of();
}
List<Long> roleIds = userRoles.stream().map(SysUserRoleDO::getRoleId).toList();
return sysRoleMapper.selectList(Wrappers.<SysRoleDO>lambdaQuery()
.in(SysRoleDO::getId, roleIds)
.eq(SysRoleDO::getDeleted, 0)
.eq(SysRoleDO::getRoleStatus, "ENABLED"))
.stream()
.map(SysRoleDO::getRoleCode)
.toList();
}
private CurrentUserView buildCurrentUser(SysUserDO user, List<String> roleCodes) {
String tenantName = "平台";
if (user.getTenantId() != null && user.getTenantId() > 0) {
tenantName = resolveTenantName(user.getTenantId());
}
String roleName = roleCodes.isEmpty() ? "未分配角色" : roleCodes.get(0);
String deptName = "";
if (user.getDeptId() != null) {
OrgDepartmentDO department = orgDepartmentMapper.selectById(user.getDeptId());
deptName = department == null ? "" : department.getDeptName();
}
return new CurrentUserView(
user.getId(),
user.getTenantId(),
user.getUsername(),
user.getRealName(),
user.getUserType(),
tenantName,
roleName,
roleCodes,
deptName,
user.getJobTitle(),
user.getMustUpdatePassword() != null && user.getMustUpdatePassword() == 1
);
}
private String resolveTenantName(Long tenantId) {
List<String> tenantNames = jdbcTemplate.query(
"""
SELECT tenant_name
FROM sys_tenant
WHERE id = ?
AND deleted = 0
LIMIT 1
""",
(rs, rowNum) -> rs.getString("tenant_name"),
tenantId
);
String tenantName = tenantNames.isEmpty() ? "" : tenantNames.getFirst();
return StringUtils.hasText(tenantName) ? tenantName : "当前租户";
}
public record LoginResult(String accessToken, CurrentUserView currentUser) {
}
public record CurrentUserView(
Long id,
Long tenantId,
String username,
String realName,
String userType,
String tenantName,
String roleName,
List<String> roleCodes,
String deptName,
String jobTitle,
boolean mustUpdatePassword
) {
}
}

View File

@@ -0,0 +1,343 @@
package com.easycard.module.user.service;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.easycard.common.auth.LoginUser;
import com.easycard.common.auth.SecurityUtils;
import com.easycard.common.exception.BusinessException;
import com.easycard.module.org.dal.entity.OrgDepartmentDO;
import com.easycard.module.org.dal.mapper.OrgDepartmentMapper;
import com.easycard.module.user.dal.entity.SysRoleDO;
import com.easycard.module.user.dal.entity.SysUserDO;
import com.easycard.module.user.dal.entity.SysUserRoleDO;
import com.easycard.module.user.dal.mapper.SysRoleMapper;
import com.easycard.module.user.dal.mapper.SysUserMapper;
import com.easycard.module.user.dal.mapper.SysUserRoleMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.util.Comparator;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;
@Service
@RequiredArgsConstructor
public class TenantUserService {
private final SysUserMapper sysUserMapper;
private final SysRoleMapper sysRoleMapper;
private final SysUserRoleMapper sysUserRoleMapper;
private final OrgDepartmentMapper orgDepartmentMapper;
private final PasswordEncoder passwordEncoder;
private final UserAuditService userAuditService;
public List<TenantUserView> listUsers(String keyword) {
LoginUser loginUser = SecurityUtils.getRequiredLoginUser();
Long tenantId = loginUser.tenantId();
List<SysUserDO> users = sysUserMapper.selectList(Wrappers.<SysUserDO>lambdaQuery()
.eq(SysUserDO::getTenantId, tenantId)
.eq(SysUserDO::getDeleted, 0)
.like(StringUtils.hasText(keyword), SysUserDO::getRealName, keyword)
.orderByAsc(SysUserDO::getMemberSort)
.orderByDesc(SysUserDO::getId));
if (users.isEmpty()) {
return List.of();
}
Map<Long, OrgDepartmentDO> deptMap = orgDepartmentMapper.selectList(Wrappers.<OrgDepartmentDO>lambdaQuery()
.eq(OrgDepartmentDO::getTenantId, tenantId)
.eq(OrgDepartmentDO::getDeleted, 0))
.stream()
.collect(Collectors.toMap(OrgDepartmentDO::getId, Function.identity()));
Map<Long, String> roleCodeMap = loadPrimaryRoleMap(users, tenantId);
return users.stream().map(user -> new TenantUserView(
user.getId(),
user.getUsername(),
user.getRealName(),
user.getMobile(),
user.getEmail(),
user.getJobTitle(),
user.getUserStatus(),
user.getMemberSort(),
user.getDeptId(),
deptMap.containsKey(user.getDeptId()) ? deptMap.get(user.getDeptId()).getDeptName() : "",
roleCodeMap.getOrDefault(user.getId(), "")
)).toList();
}
@Transactional
public TenantUserView createUser(CreateOrUpdateUserRequest request) {
LoginUser loginUser = SecurityUtils.getRequiredLoginUser();
validateDepartment(loginUser.tenantId(), request.deptId());
SysUserDO existedUser = sysUserMapper.selectOne(Wrappers.<SysUserDO>lambdaQuery()
.eq(SysUserDO::getTenantId, loginUser.tenantId())
.eq(SysUserDO::getUsername, request.username())
.eq(SysUserDO::getDeleted, 0)
.last("LIMIT 1"));
if (existedUser != null) {
throw new BusinessException("USERNAME_DUPLICATED", "登录账号已存在");
}
validateUserStatus(request.userStatus());
SysRoleDO role = getRoleByCode(loginUser.tenantId(), request.roleCode());
SysUserDO user = new SysUserDO();
user.setTenantId(loginUser.tenantId());
user.setUserType("TENANT");
user.setUsername(request.username());
user.setPasswordHash(passwordEncoder.encode("123456"));
user.setRealName(request.realName());
user.setNickName(request.realName());
user.setGender("UNKNOWN");
user.setMobile(request.mobile());
user.setEmail(request.email());
user.setDeptId(request.deptId());
user.setJobTitle(request.jobTitle());
user.setUserStatus(request.userStatus());
user.setMemberSort(nextMemberSort(loginUser.tenantId()));
user.setMustUpdatePassword(1);
user.setCreatedBy(loginUser.userId());
user.setUpdatedBy(loginUser.userId());
user.setDeleted(0);
sysUserMapper.insert(user);
SysUserRoleDO userRole = new SysUserRoleDO();
userRole.setTenantId(loginUser.tenantId());
userRole.setUserId(user.getId());
userRole.setRoleId(role.getId());
userRole.setCreatedBy(loginUser.userId());
sysUserRoleMapper.insert(userRole);
userAuditService.recordOperation(loginUser.tenantId(), loginUser.userId(), loginUser.realName(), "USER", "SYS_USER", user.getId(), "CREATE");
return getUser(user.getId());
}
@Transactional
public TenantUserView updateUser(Long userId, CreateOrUpdateUserRequest request) {
LoginUser loginUser = SecurityUtils.getRequiredLoginUser();
SysUserDO user = getTenantUser(userId, loginUser.tenantId());
validateDepartment(loginUser.tenantId(), request.deptId());
validateUserStatus(request.userStatus());
SysRoleDO role = getRoleByCode(loginUser.tenantId(), request.roleCode());
user.setRealName(request.realName());
user.setNickName(request.realName());
user.setMobile(request.mobile());
user.setEmail(request.email());
user.setDeptId(request.deptId());
user.setJobTitle(request.jobTitle());
user.setUserStatus(request.userStatus());
user.setUpdatedBy(loginUser.userId());
sysUserMapper.updateById(user);
sysUserRoleMapper.delete(Wrappers.<SysUserRoleDO>lambdaQuery()
.eq(SysUserRoleDO::getTenantId, loginUser.tenantId())
.eq(SysUserRoleDO::getUserId, userId));
SysUserRoleDO userRole = new SysUserRoleDO();
userRole.setTenantId(loginUser.tenantId());
userRole.setUserId(user.getId());
userRole.setRoleId(role.getId());
userRole.setCreatedBy(loginUser.userId());
sysUserRoleMapper.insert(userRole);
userAuditService.recordOperation(loginUser.tenantId(), loginUser.userId(), loginUser.realName(), "USER", "SYS_USER", user.getId(), "UPDATE");
return getUser(userId);
}
@Transactional
public TenantUserView updateUserStatus(Long userId, String userStatus) {
LoginUser loginUser = SecurityUtils.getRequiredLoginUser();
validateUserStatus(userStatus);
SysUserDO user = getTenantUser(userId, loginUser.tenantId());
if (userStatus.equals(user.getUserStatus())) {
return getUser(userId);
}
user.setUserStatus(userStatus);
user.setUpdatedBy(loginUser.userId());
sysUserMapper.updateById(user);
userAuditService.recordOperation(
loginUser.tenantId(),
loginUser.userId(),
loginUser.realName(),
"USER",
"SYS_USER",
user.getId(),
"UPDATE_STATUS"
);
return getUser(userId);
}
@Transactional
public void sortUsers(List<Long> userIds) {
LoginUser loginUser = SecurityUtils.getRequiredLoginUser();
if (userIds == null || userIds.isEmpty()) {
throw new BusinessException("USER_SORT_INVALID", "排序数据不能为空");
}
Set<Long> uniqueUserIds = new HashSet<>(userIds);
if (uniqueUserIds.size() != userIds.size()) {
throw new BusinessException("USER_SORT_INVALID", "排序数据存在重复成员");
}
List<SysUserDO> tenantUsers = sysUserMapper.selectList(Wrappers.<SysUserDO>lambdaQuery()
.eq(SysUserDO::getTenantId, loginUser.tenantId())
.eq(SysUserDO::getDeleted, 0)
.select(SysUserDO::getId));
Set<Long> expectedUserIds = tenantUsers.stream().map(SysUserDO::getId).collect(Collectors.toSet());
if (expectedUserIds.size() != uniqueUserIds.size() || !expectedUserIds.equals(uniqueUserIds)) {
throw new BusinessException("USER_SORT_INVALID", "排序数据必须包含当前租户全部成员");
}
Map<Long, SysUserDO> userMap = sysUserMapper.selectBatchIds(userIds).stream()
.filter(item -> loginUser.tenantId().equals(item.getTenantId()) && Integer.valueOf(0).equals(item.getDeleted()))
.collect(Collectors.toMap(SysUserDO::getId, Function.identity()));
for (int i = 0; i < userIds.size(); i++) {
Long sortedUserId = userIds.get(i);
SysUserDO user = userMap.get(sortedUserId);
if (user == null) {
throw new BusinessException("USER_SORT_INVALID", "成员不存在或不属于当前租户");
}
user.setMemberSort((i + 1) * 10);
user.setUpdatedBy(loginUser.userId());
sysUserMapper.updateById(user);
}
userAuditService.recordOperation(
loginUser.tenantId(),
loginUser.userId(),
loginUser.realName(),
"USER",
"SYS_USER",
0L,
"SORT"
);
}
@Transactional
public void resetPassword(Long userId) {
LoginUser loginUser = SecurityUtils.getRequiredLoginUser();
SysUserDO user = getTenantUser(userId, loginUser.tenantId());
user.setPasswordHash(passwordEncoder.encode("123456"));
user.setMustUpdatePassword(1);
user.setUpdatedBy(loginUser.userId());
sysUserMapper.updateById(user);
userAuditService.recordOperation(loginUser.tenantId(), loginUser.userId(), loginUser.realName(), "USER", "SYS_USER", user.getId(), "RESET_PASSWORD");
}
public TenantUserView getUser(Long userId) {
LoginUser loginUser = SecurityUtils.getRequiredLoginUser();
SysUserDO user = getTenantUser(userId, loginUser.tenantId());
Map<Long, String> roleMap = loadPrimaryRoleMap(List.of(user), loginUser.tenantId());
String deptName = "";
if (user.getDeptId() != null) {
OrgDepartmentDO department = orgDepartmentMapper.selectById(user.getDeptId());
deptName = department == null ? "" : department.getDeptName();
}
return new TenantUserView(
user.getId(),
user.getUsername(),
user.getRealName(),
user.getMobile(),
user.getEmail(),
user.getJobTitle(),
user.getUserStatus(),
user.getMemberSort(),
user.getDeptId(),
deptName,
roleMap.getOrDefault(user.getId(), "")
);
}
private Map<Long, String> loadPrimaryRoleMap(List<SysUserDO> users, Long tenantId) {
List<Long> userIds = users.stream().map(SysUserDO::getId).toList();
List<SysUserRoleDO> userRoles = sysUserRoleMapper.selectList(Wrappers.<SysUserRoleDO>lambdaQuery()
.eq(SysUserRoleDO::getTenantId, tenantId)
.in(SysUserRoleDO::getUserId, userIds));
if (userRoles.isEmpty()) {
return Map.of();
}
List<Long> roleIds = userRoles.stream().map(SysUserRoleDO::getRoleId).distinct().toList();
Map<Long, SysRoleDO> roleMap = sysRoleMapper.selectList(Wrappers.<SysRoleDO>lambdaQuery()
.in(SysRoleDO::getId, roleIds)
.eq(SysRoleDO::getDeleted, 0))
.stream()
.collect(Collectors.toMap(SysRoleDO::getId, Function.identity()));
return userRoles.stream()
.sorted(Comparator.comparing(SysUserRoleDO::getRoleId))
.collect(Collectors.toMap(
SysUserRoleDO::getUserId,
item -> roleMap.containsKey(item.getRoleId()) ? roleMap.get(item.getRoleId()).getRoleCode() : "",
(left, right) -> left
));
}
private void validateDepartment(Long tenantId, Long deptId) {
if (deptId == null) {
return;
}
OrgDepartmentDO department = orgDepartmentMapper.selectById(deptId);
if (department == null || Integer.valueOf(1).equals(department.getDeleted()) || !tenantId.equals(department.getTenantId())) {
throw new BusinessException("DEPARTMENT_NOT_FOUND", "所属组织不存在");
}
}
private void validateUserStatus(String userStatus) {
if (!"ENABLED".equals(userStatus) && !"DISABLED".equals(userStatus)) {
throw new BusinessException("USER_STATUS_INVALID", "成员状态仅支持启用或停用");
}
}
private Integer nextMemberSort(Long tenantId) {
SysUserDO lastUser = sysUserMapper.selectOne(Wrappers.<SysUserDO>lambdaQuery()
.eq(SysUserDO::getTenantId, tenantId)
.eq(SysUserDO::getDeleted, 0)
.orderByDesc(SysUserDO::getMemberSort)
.orderByDesc(SysUserDO::getId)
.last("LIMIT 1"));
int baseSort = lastUser == null || lastUser.getMemberSort() == null ? 0 : lastUser.getMemberSort();
return baseSort + 10;
}
private SysRoleDO getRoleByCode(Long tenantId, String roleCode) {
SysRoleDO role = sysRoleMapper.selectOne(Wrappers.<SysRoleDO>lambdaQuery()
.eq(SysRoleDO::getTenantId, tenantId)
.eq(SysRoleDO::getRoleCode, roleCode)
.eq(SysRoleDO::getDeleted, 0)
.last("LIMIT 1"));
if (role == null) {
throw new BusinessException("ROLE_NOT_FOUND", "角色不存在");
}
return role;
}
private SysUserDO getTenantUser(Long userId, Long tenantId) {
SysUserDO user = sysUserMapper.selectById(userId);
if (user == null || Integer.valueOf(1).equals(user.getDeleted()) || !tenantId.equals(user.getTenantId())) {
throw new BusinessException("USER_NOT_FOUND", "用户不存在");
}
return user;
}
public record CreateOrUpdateUserRequest(
String username,
String realName,
String mobile,
String email,
Long deptId,
String jobTitle,
String userStatus,
String roleCode
) {
}
public record TenantUserView(
Long id,
String username,
String realName,
String mobile,
String email,
String jobTitle,
String userStatus,
Integer memberSort,
Long deptId,
String deptName,
String roleCode
) {
}
}

View File

@@ -0,0 +1,48 @@
package com.easycard.module.user.service;
import com.easycard.module.user.dal.entity.SysLoginLogDO;
import com.easycard.module.user.dal.entity.SysOperationLogDO;
import com.easycard.module.user.dal.mapper.SysLoginLogMapper;
import com.easycard.module.user.dal.mapper.SysOperationLogMapper;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
@Service
public class UserAuditService {
private final SysLoginLogMapper sysLoginLogMapper;
private final SysOperationLogMapper sysOperationLogMapper;
public UserAuditService(SysLoginLogMapper sysLoginLogMapper, SysOperationLogMapper sysOperationLogMapper) {
this.sysLoginLogMapper = sysLoginLogMapper;
this.sysOperationLogMapper = sysOperationLogMapper;
}
public void recordLogin(Long tenantId, Long userId, String userType, String loginStatus, String clientIp, String userAgent, String failReason) {
SysLoginLogDO loginLog = new SysLoginLogDO();
loginLog.setTenantId(tenantId == null ? 0L : tenantId);
loginLog.setUserId(userId);
loginLog.setUserType(userType == null ? "TENANT" : userType);
loginLog.setLoginType("PASSWORD");
loginLog.setLoginStatus(loginStatus);
loginLog.setClientIp(clientIp);
loginLog.setUserAgent(userAgent);
loginLog.setFailReason(failReason);
loginLog.setLoginAt(LocalDateTime.now());
sysLoginLogMapper.insert(loginLog);
}
public void recordOperation(Long tenantId, Long userId, String userName, String moduleName, String bizType, Long bizId, String operationType) {
SysOperationLogDO operationLog = new SysOperationLogDO();
operationLog.setTenantId(tenantId == null ? 0L : tenantId);
operationLog.setUserId(userId);
operationLog.setUserName(userName);
operationLog.setModuleName(moduleName);
operationLog.setBizType(bizType);
operationLog.setBizId(bizId);
operationLog.setOperationType(operationType);
operationLog.setOperatedAt(LocalDateTime.now());
sysOperationLogMapper.insert(operationLog);
}
}