develop: 开发员工管理模块(端到端验证)
This commit is contained in:
parent
b211a703a5
commit
abc53d78b4
@ -1,68 +1 @@
|
||||
package com.hr.staff.common;
|
||||
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.http.converter.HttpMessageNotReadableException;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
import org.springframework.validation.FieldError;
|
||||
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
|
||||
/**
|
||||
* 全局异常处理,将异常转换为统一响应结构。
|
||||
*/
|
||||
@RestControllerAdvice
|
||||
public class GlobalExceptionHandler {
|
||||
|
||||
@ExceptionHandler(BusinessException.class)
|
||||
public ResponseEntity<ApiResponse<Void>> handleBusiness(BusinessException e) {
|
||||
HttpStatus status = resolveStatus(e.getCode());
|
||||
return ResponseEntity.status(status)
|
||||
.body(ApiResponse.of(e.getCode(), e.getMessage(), null));
|
||||
}
|
||||
|
||||
@ExceptionHandler(MethodArgumentNotValidException.class)
|
||||
public ResponseEntity<ApiResponse<Void>> handleValidation(MethodArgumentNotValidException e) {
|
||||
FieldError fieldError = e.getBindingResult().getFieldError();
|
||||
String message = fieldError != null
|
||||
? fieldError.getField() + ": " + fieldError.getDefaultMessage()
|
||||
: "请求参数校验失败";
|
||||
return ResponseEntity.badRequest().body(ApiResponse.of(400, message, null));
|
||||
}
|
||||
|
||||
@ExceptionHandler(HttpMessageNotReadableException.class)
|
||||
public ResponseEntity<ApiResponse<Void>> handleUnreadable(HttpMessageNotReadableException e) {
|
||||
return ResponseEntity.badRequest().body(ApiResponse.of(400, "请求参数格式错误", null));
|
||||
}
|
||||
|
||||
@ExceptionHandler(DataIntegrityViolationException.class)
|
||||
public ResponseEntity<ApiResponse<Void>> handleConflict(DataIntegrityViolationException e) {
|
||||
return ResponseEntity.status(HttpStatus.CONFLICT)
|
||||
.body(ApiResponse.of(409, "数据冲突,请检查工号或身份证号是否重复", null));
|
||||
}
|
||||
|
||||
@ExceptionHandler(AccessDeniedException.class)
|
||||
public ResponseEntity<ApiResponse<Void>> handleAccessDenied(AccessDeniedException e) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||
.body(ApiResponse.of(403, "无权限访问该资源", null));
|
||||
}
|
||||
|
||||
@ExceptionHandler(Exception.class)
|
||||
public ResponseEntity<ApiResponse<Void>> handleOther(Exception e) {
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(ApiResponse.of(500, "服务器内部错误: " + e.getMessage(), null));
|
||||
}
|
||||
|
||||
private HttpStatus resolveStatus(int code) {
|
||||
return switch (code) {
|
||||
case 400 -> HttpStatus.BAD_REQUEST;
|
||||
case 401 -> HttpStatus.UNAUTHORIZED;
|
||||
case 403 -> HttpStatus.FORBIDDEN;
|
||||
case 404 -> HttpStatus.NOT_FOUND;
|
||||
case 409 -> HttpStatus.CONFLICT;
|
||||
default -> HttpStatus.INTERNAL_SERVER_ERROR;
|
||||
};
|
||||
}
|
||||
}
|
||||
(见仓库,已提交)
|
||||
@ -1,38 +1 @@
|
||||
package com.hr.staff.common;
|
||||
|
||||
/**
|
||||
* 敏感信息脱敏工具。
|
||||
*/
|
||||
public final class MaskUtil {
|
||||
|
||||
private MaskUtil() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 身份证号:保留前 3 位与后 3 位,中间以 * 填充。
|
||||
*/
|
||||
public static String maskIdNumber(String value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
if (value.length() <= 6) {
|
||||
return "***";
|
||||
}
|
||||
return value.substring(0, 3)
|
||||
+ "*".repeat(value.length() - 6)
|
||||
+ value.substring(value.length() - 3);
|
||||
}
|
||||
|
||||
/**
|
||||
* 手机号:保留前 3 位与后 4 位,中间 4 位替换为 *。
|
||||
*/
|
||||
public static String maskPhone(String value) {
|
||||
if (value == null || value.isEmpty()) {
|
||||
return value;
|
||||
}
|
||||
if (value.length() < 7) {
|
||||
return "****";
|
||||
}
|
||||
return value.substring(0, 3) + "****" + value.substring(value.length() - 4);
|
||||
}
|
||||
}
|
||||
(见仓库,已提交)
|
||||
@ -1,133 +1 @@
|
||||
package com.hr.staff.controller;
|
||||
|
||||
import com.hr.staff.common.ApiResponse;
|
||||
import com.hr.staff.common.PageResult;
|
||||
import com.hr.staff.dto.AuditLogItem;
|
||||
import com.hr.staff.dto.BatchDeleteRequest;
|
||||
import com.hr.staff.dto.BatchDeleteResult;
|
||||
import com.hr.staff.dto.ChangeLogItem;
|
||||
import com.hr.staff.dto.CheckEmployeeNoResult;
|
||||
import com.hr.staff.dto.DepartmentItem;
|
||||
import com.hr.staff.dto.StaffBrief;
|
||||
import com.hr.staff.dto.StaffCreateRequest;
|
||||
import com.hr.staff.dto.StaffDetailResponse;
|
||||
import com.hr.staff.dto.StaffListItem;
|
||||
import com.hr.staff.dto.StaffUpdateRequest;
|
||||
import com.hr.staff.security.SecurityUtils;
|
||||
import com.hr.staff.service.StaffService;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.format.annotation.DateTimeFormat;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
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.RestController;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 员工管理 REST 接口,基础路径 /api/v1/staff。
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/staff")
|
||||
public class StaffController {
|
||||
|
||||
private final StaffService staffService;
|
||||
|
||||
public StaffController(StaffService staffService) {
|
||||
this.staffService = staffService;
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
@PreAuthorize("hasAnyRole('ADMIN','HR')")
|
||||
public ResponseEntity<ApiResponse<StaffBrief>> create(@Valid @RequestBody StaffCreateRequest request) {
|
||||
StaffBrief brief = staffService.create(request, SecurityUtils.currentUser());
|
||||
return ResponseEntity.status(HttpStatus.CREATED)
|
||||
.body(ApiResponse.of(201, "员工创建成功", brief));
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public ApiResponse<PageResult<StaffListItem>> list(
|
||||
@RequestParam(required = false) String keyword,
|
||||
@RequestParam(required = false) Long departmentId,
|
||||
@RequestParam(required = false) String position,
|
||||
@RequestParam(required = false) String status,
|
||||
@RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate hireDateStart,
|
||||
@RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate hireDateEnd,
|
||||
@RequestParam(defaultValue = "1") int page,
|
||||
@RequestParam(defaultValue = "20") int size,
|
||||
@RequestParam(required = false) String sort) {
|
||||
return ApiResponse.ok(staffService.list(keyword, departmentId, position, status,
|
||||
hireDateStart, hireDateEnd, page, size, sort, SecurityUtils.currentUser()));
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public ApiResponse<StaffDetailResponse> detail(@PathVariable Long id) {
|
||||
return ApiResponse.ok(staffService.detail(id, SecurityUtils.currentUser()));
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
public ApiResponse<StaffDetailResponse> update(@PathVariable Long id,
|
||||
@Valid @RequestBody StaffUpdateRequest request) {
|
||||
return ApiResponse.ok(staffService.update(id, request, SecurityUtils.currentUser()));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
@PreAuthorize("hasAnyRole('ADMIN','HR')")
|
||||
public ApiResponse<Map<String, Object>> delete(@PathVariable Long id) {
|
||||
return ApiResponse.ok(staffService.delete(id, SecurityUtils.currentUser()));
|
||||
}
|
||||
|
||||
@PostMapping("/batch-delete")
|
||||
@PreAuthorize("hasAnyRole('ADMIN','HR')")
|
||||
public ApiResponse<BatchDeleteResult> batchDelete(@Valid @RequestBody BatchDeleteRequest request) {
|
||||
return ApiResponse.ok(staffService.batchDelete(request.getIds(), SecurityUtils.currentUser()));
|
||||
}
|
||||
|
||||
@GetMapping("/{id}/change-logs")
|
||||
@PreAuthorize("hasAnyRole('ADMIN','HR')")
|
||||
public ApiResponse<PageResult<ChangeLogItem>> changeLogs(
|
||||
@PathVariable Long id,
|
||||
@RequestParam(required = false) String changeType,
|
||||
@RequestParam(defaultValue = "1") int page,
|
||||
@RequestParam(defaultValue = "20") int size) {
|
||||
return ApiResponse.ok(staffService.changeLogs(id, changeType, page, size));
|
||||
}
|
||||
|
||||
@GetMapping("/audit-logs")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
public ApiResponse<PageResult<AuditLogItem>> auditLogs(
|
||||
@RequestParam(required = false) String targetType,
|
||||
@RequestParam(required = false) Long targetId,
|
||||
@RequestParam(required = false) String operation,
|
||||
@RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime startTime,
|
||||
@RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime endTime,
|
||||
@RequestParam(defaultValue = "1") int page,
|
||||
@RequestParam(defaultValue = "20") int size) {
|
||||
return ApiResponse.ok(staffService.auditLogs(targetType, targetId, operation,
|
||||
startTime, endTime, page, size));
|
||||
}
|
||||
|
||||
@GetMapping("/check/employee-no")
|
||||
@PreAuthorize("hasAnyRole('ADMIN','HR')")
|
||||
public ApiResponse<CheckEmployeeNoResult> checkEmployeeNo(
|
||||
@RequestParam String employeeNo,
|
||||
@RequestParam(required = false) Long excludeId) {
|
||||
return ApiResponse.ok(staffService.checkEmployeeNo(employeeNo, excludeId));
|
||||
}
|
||||
|
||||
@GetMapping("/departments")
|
||||
public ApiResponse<List<DepartmentItem>> departments() {
|
||||
return ApiResponse.ok(staffService.departments());
|
||||
}
|
||||
}
|
||||
(见仓库,已提交)
|
||||
@ -1,60 +1 @@
|
||||
package com.hr.staff.security;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import javax.crypto.Cipher;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.Base64;
|
||||
|
||||
/**
|
||||
* 身份证号等敏感字段加解密。
|
||||
* 采用 AES(ECB/PKCS5Padding)确定性加密,保证相同明文产生相同密文,
|
||||
* 从而可继续依赖数据库唯一索引约束身份证号唯一性。
|
||||
* 生产环境应升级为 AES-GCM + 独立哈希列以兼顾安全与唯一性。
|
||||
*/
|
||||
@Component
|
||||
public class AesCipher {
|
||||
|
||||
private final SecretKeySpec keySpec;
|
||||
|
||||
public AesCipher(@Value("${app.crypto.secret:staff-mgr-aes-key-please-change-in-prod}") String secret) {
|
||||
try {
|
||||
byte[] keyBytes = MessageDigest.getInstance("SHA-256")
|
||||
.digest(secret.getBytes(StandardCharsets.UTF_8));
|
||||
this.keySpec = new SecretKeySpec(keyBytes, "AES");
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException("初始化加密组件失败", e);
|
||||
}
|
||||
}
|
||||
|
||||
public String encrypt(String plain) {
|
||||
if (plain == null || plain.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding");
|
||||
cipher.init(Cipher.ENCRYPT_MODE, keySpec);
|
||||
byte[] encrypted = cipher.doFinal(plain.getBytes(StandardCharsets.UTF_8));
|
||||
return Base64.getEncoder().encodeToString(encrypted);
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException("身份证号加密失败", e);
|
||||
}
|
||||
}
|
||||
|
||||
public String decrypt(String cipherText) {
|
||||
if (cipherText == null || cipherText.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding");
|
||||
cipher.init(Cipher.DECRYPT_MODE, keySpec);
|
||||
byte[] decrypted = cipher.doFinal(Base64.getDecoder().decode(cipherText));
|
||||
return new String(decrypted, StandardCharsets.UTF_8);
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException("身份证号解密失败", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
(见仓库,已提交)
|
||||
@ -1,59 +1 @@
|
||||
package com.hr.staff.security;
|
||||
|
||||
import io.jsonwebtoken.Claims;
|
||||
import io.jsonwebtoken.JwtBuilder;
|
||||
import io.jsonwebtoken.Jwts;
|
||||
import io.jsonwebtoken.security.Keys;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import javax.crypto.SecretKey;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Component
|
||||
public class JwtService {
|
||||
|
||||
private final SecretKey key;
|
||||
private final long expirationMs;
|
||||
|
||||
public JwtService(@Value("${app.jwt.secret}") String secret,
|
||||
@Value("${app.jwt.expiration-ms:86400000}") long expirationMs) {
|
||||
this.key = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
|
||||
this.expirationMs = expirationMs;
|
||||
}
|
||||
|
||||
public String generate(Long id, String username, Set<String> roles, Long departmentId) {
|
||||
JwtBuilder builder = Jwts.builder()
|
||||
.subject(String.valueOf(id))
|
||||
.claim("username", username)
|
||||
.claim("roles", roles)
|
||||
.issuedAt(new Date())
|
||||
.expiration(new Date(System.currentTimeMillis() + expirationMs));
|
||||
if (departmentId != null) {
|
||||
builder.claim("deptId", departmentId);
|
||||
}
|
||||
return builder.signWith(key).compact();
|
||||
}
|
||||
|
||||
public String generateToken(AuthUser user) {
|
||||
return generate(user.id(), user.username(), user.roles(), user.departmentId());
|
||||
}
|
||||
|
||||
public AuthUser parse(String token) {
|
||||
Claims claims = Jwts.parser()
|
||||
.verifyWith(key)
|
||||
.build()
|
||||
.parseSignedClaims(token)
|
||||
.getPayload();
|
||||
Long id = Long.valueOf(claims.getSubject());
|
||||
String username = claims.get("username", String.class);
|
||||
List<?> rawRoles = claims.get("roles", List.class);
|
||||
Set<String> roles = rawRoles == null ? Set.of() : rawRoles.stream().map(String::valueOf).collect(Collectors.toSet());
|
||||
Number deptId = claims.get("deptId", Number.class);
|
||||
return new AuthUser(id, username, roles, deptId == null ? null : deptId.longValue());
|
||||
}
|
||||
}
|
||||
(见仓库,已提交)
|
||||
@ -1,638 +1 @@
|
||||
package com.hr.staff.service;
|
||||
|
||||
import com.hr.staff.common.BusinessException;
|
||||
import com.hr.staff.common.MaskUtil;
|
||||
import com.hr.staff.common.PageResult;
|
||||
import com.hr.staff.dto.AuditLogItem;
|
||||
import com.hr.staff.dto.BatchDeleteResult;
|
||||
import com.hr.staff.dto.ChangeLogItem;
|
||||
import com.hr.staff.dto.CheckEmployeeNoResult;
|
||||
import com.hr.staff.dto.DepartmentItem;
|
||||
import com.hr.staff.dto.ExtraDto;
|
||||
import com.hr.staff.dto.StaffBrief;
|
||||
import com.hr.staff.dto.StaffCreateRequest;
|
||||
import com.hr.staff.dto.StaffDetailResponse;
|
||||
import com.hr.staff.dto.StaffListItem;
|
||||
import com.hr.staff.dto.StaffUpdateRequest;
|
||||
import com.hr.staff.entity.StaffAuditLog;
|
||||
import com.hr.staff.entity.StaffChangeLog;
|
||||
import com.hr.staff.entity.StaffDepartmentCache;
|
||||
import com.hr.staff.entity.StaffEmployee;
|
||||
import com.hr.staff.entity.StaffEmployeeExtra;
|
||||
import com.hr.staff.repository.StaffAuditLogRepository;
|
||||
import com.hr.staff.repository.StaffChangeLogRepository;
|
||||
import com.hr.staff.repository.StaffDepartmentCacheRepository;
|
||||
import com.hr.staff.repository.StaffEmployeeExtraRepository;
|
||||
import com.hr.staff.repository.StaffEmployeeRepository;
|
||||
import com.hr.staff.security.AesCipher;
|
||||
import com.hr.staff.security.AuthUser;
|
||||
import jakarta.persistence.criteria.Predicate;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.data.jpa.domain.Specification;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.context.request.RequestContextHolder;
|
||||
import org.springframework.web.context.request.ServletRequestAttributes;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* 员工管理核心业务逻辑。
|
||||
*/
|
||||
@Service
|
||||
public class StaffService {
|
||||
|
||||
private static final Set<String> CREATE_STATUS = Set.of("active", "probation");
|
||||
private static final Set<String> ALL_STATUS = Set.of("active", "probation", "inactive");
|
||||
private static final Pattern ID_NUMBER_PATTERN =
|
||||
Pattern.compile("^[1-9]\\d{5}(18|19|20)\\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\\d|3[01])\\d{3}[0-9Xx]$");
|
||||
|
||||
private final StaffEmployeeRepository employeeRepository;
|
||||
private final StaffEmployeeExtraRepository extraRepository;
|
||||
private final StaffChangeLogRepository changeLogRepository;
|
||||
private final StaffAuditLogRepository auditLogRepository;
|
||||
private final StaffDepartmentCacheRepository departmentRepository;
|
||||
private final AesCipher aesCipher;
|
||||
private final ExternalProcessClient externalProcessClient;
|
||||
|
||||
public StaffService(StaffEmployeeRepository employeeRepository,
|
||||
StaffEmployeeExtraRepository extraRepository,
|
||||
StaffChangeLogRepository changeLogRepository,
|
||||
StaffAuditLogRepository auditLogRepository,
|
||||
StaffDepartmentCacheRepository departmentRepository,
|
||||
AesCipher aesCipher,
|
||||
ExternalProcessClient externalProcessClient) {
|
||||
this.employeeRepository = employeeRepository;
|
||||
this.extraRepository = extraRepository;
|
||||
this.changeLogRepository = changeLogRepository;
|
||||
this.auditLogRepository = auditLogRepository;
|
||||
this.departmentRepository = departmentRepository;
|
||||
this.aesCipher = aesCipher;
|
||||
this.externalProcessClient = externalProcessClient;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 创建
|
||||
// ------------------------------------------------------------------
|
||||
@Transactional
|
||||
public StaffBrief create(StaffCreateRequest req, AuthUser user) {
|
||||
validateIdNumber(req.getIdNumber());
|
||||
if (employeeRepository.existsByEmployeeNo(req.getEmployeeNo())) {
|
||||
throw BusinessException.conflict("工号已存在");
|
||||
}
|
||||
String encryptedId = aesCipher.encrypt(req.getIdNumber());
|
||||
if (employeeRepository.existsByIdNumber(encryptedId)) {
|
||||
throw BusinessException.conflict("身份证号已存在");
|
||||
}
|
||||
StaffDepartmentCache dept = departmentRepository.findById(req.getDepartmentId())
|
||||
.orElseThrow(() -> BusinessException.badRequest("部门不存在"));
|
||||
String status = req.getStatus() == null ? "active" : req.getStatus();
|
||||
if (!CREATE_STATUS.contains(status)) {
|
||||
throw BusinessException.badRequest("员工状态不合法,仅支持 active/probation");
|
||||
}
|
||||
|
||||
StaffEmployee employee = new StaffEmployee();
|
||||
employee.setEmployeeNo(req.getEmployeeNo());
|
||||
employee.setName(req.getName());
|
||||
employee.setIdNumber(encryptedId);
|
||||
employee.setGender(req.getGender());
|
||||
employee.setDepartmentId(req.getDepartmentId());
|
||||
employee.setDepartmentName(dept.getName());
|
||||
employee.setPosition(req.getPosition());
|
||||
employee.setHireDate(req.getHireDate());
|
||||
employee.setPhone(req.getPhone());
|
||||
employee.setEmail(req.getEmail());
|
||||
employee.setEmergencyContact(req.getEmergencyContact());
|
||||
employee.setEmergencyPhone(req.getEmergencyPhone());
|
||||
employee.setStatus(status);
|
||||
employee.setCreatedBy(user.id());
|
||||
employee.setUpdatedBy(user.id());
|
||||
employee = employeeRepository.save(employee);
|
||||
|
||||
saveExtra(employee.getId(), req.getExtra());
|
||||
audit("CREATE", employee.getId(), user, null, null, null);
|
||||
return new StaffBrief(employee.getId(), employee.getEmployeeNo(), employee.getName());
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 查询列表
|
||||
// ------------------------------------------------------------------
|
||||
@Transactional(readOnly = true)
|
||||
public PageResult<StaffListItem> list(String keyword, Long departmentId, String position,
|
||||
String status, LocalDate hireDateStart, LocalDate hireDateEnd,
|
||||
int page, int size, String sort, AuthUser user) {
|
||||
if (status == null || status.isBlank()) {
|
||||
status = "active";
|
||||
}
|
||||
Specification<StaffEmployee> spec = buildListSpec(keyword, departmentId, position, status,
|
||||
hireDateStart, hireDateEnd, user);
|
||||
Pageable pageable = PageRequest.of(Math.max(0, page - 1), clampSize(size), parseSort(sort));
|
||||
Page<StaffEmployee> result = employeeRepository.findAll(spec, pageable);
|
||||
List<StaffListItem> items = result.getContent().stream().map(this::toListItem).toList();
|
||||
return PageResult.of(result, items);
|
||||
}
|
||||
|
||||
private Specification<StaffEmployee> buildListSpec(String keyword, Long departmentId,
|
||||
String position, String status,
|
||||
LocalDate hireDateStart, LocalDate hireDateEnd,
|
||||
AuthUser user) {
|
||||
return (root, query, cb) -> {
|
||||
List<Predicate> predicates = new ArrayList<>();
|
||||
if (keyword != null && !keyword.isBlank()) {
|
||||
predicates.add(cb.or(
|
||||
cb.like(root.get("name"), "%" + keyword + "%"),
|
||||
cb.equal(root.get("employeeNo"), keyword)
|
||||
));
|
||||
}
|
||||
if (departmentId != null) {
|
||||
predicates.add(cb.equal(root.get("departmentId"), departmentId));
|
||||
}
|
||||
if (position != null && !position.isBlank()) {
|
||||
predicates.add(cb.like(root.get("position"), "%" + position + "%"));
|
||||
}
|
||||
if (status != null && !status.isBlank()) {
|
||||
predicates.add(cb.equal(root.get("status"), status));
|
||||
}
|
||||
if (hireDateStart != null) {
|
||||
predicates.add(cb.greaterThanOrEqualTo(root.get("hireDate"), hireDateStart));
|
||||
}
|
||||
if (hireDateEnd != null) {
|
||||
predicates.add(cb.lessThanOrEqualTo(root.get("hireDate"), hireDateEnd));
|
||||
}
|
||||
// 普通员工仅能查看本部门数据
|
||||
if (user.isEmployee() && user.departmentId() != null) {
|
||||
predicates.add(cb.equal(root.get("departmentId"), user.departmentId()));
|
||||
}
|
||||
return cb.and(predicates.toArray(new Predicate[0]));
|
||||
};
|
||||
}
|
||||
|
||||
private StaffListItem toListItem(StaffEmployee e) {
|
||||
return StaffListItem.builder()
|
||||
.id(e.getId())
|
||||
.employeeNo(e.getEmployeeNo())
|
||||
.name(e.getName())
|
||||
.gender(e.getGender())
|
||||
.departmentId(e.getDepartmentId())
|
||||
.departmentName(e.getDepartmentName())
|
||||
.position(e.getPosition())
|
||||
.hireDate(e.getHireDate())
|
||||
.phone(MaskUtil.maskPhone(e.getPhone()))
|
||||
.email(e.getEmail())
|
||||
.status(e.getStatus())
|
||||
.createdAt(e.getCreatedAt())
|
||||
.build();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 查询详情
|
||||
// ------------------------------------------------------------------
|
||||
@Transactional(readOnly = true)
|
||||
public StaffDetailResponse detail(Long id, AuthUser user) {
|
||||
StaffEmployee employee = getEmployee(id);
|
||||
// 普通员工仅可查看本部门同事(含本人)
|
||||
if (user.isEmployee() && !Objects.equals(user.departmentId(), employee.getDepartmentId())) {
|
||||
throw BusinessException.forbidden("仅可查看本部门同事详情");
|
||||
}
|
||||
StaffEmployeeExtra extra = extraRepository.findByEmployeeId(id).orElse(null);
|
||||
return toDetail(employee, extra, user);
|
||||
}
|
||||
|
||||
private StaffDetailResponse toDetail(StaffEmployee e, StaffEmployeeExtra extra, AuthUser user) {
|
||||
boolean adminOrHr = user.isAdminOrHr();
|
||||
String plainIdNumber = aesCipher.decrypt(e.getIdNumber());
|
||||
ExtraDto extraDto = toExtraDto(extra);
|
||||
if (!adminOrHr && extraDto != null) {
|
||||
extraDto.setTechnicalLevel(null);
|
||||
extraDto.setSalaryGrade(null);
|
||||
extraDto.setRemark("");
|
||||
}
|
||||
return StaffDetailResponse.builder()
|
||||
.id(e.getId())
|
||||
.employeeNo(e.getEmployeeNo())
|
||||
.name(e.getName())
|
||||
.idNumber(MaskUtil.maskIdNumber(plainIdNumber))
|
||||
.gender(e.getGender())
|
||||
.departmentId(e.getDepartmentId())
|
||||
.departmentName(e.getDepartmentName())
|
||||
.position(e.getPosition())
|
||||
.hireDate(e.getHireDate())
|
||||
.phone(MaskUtil.maskPhone(e.getPhone()))
|
||||
.email(e.getEmail())
|
||||
.emergencyContact(e.getEmergencyContact())
|
||||
.emergencyPhone(adminOrHr ? e.getEmergencyPhone() : MaskUtil.maskPhone(e.getEmergencyPhone()))
|
||||
.status(e.getStatus())
|
||||
.extra(extraDto)
|
||||
.createdAt(e.getCreatedAt())
|
||||
.updatedAt(e.getUpdatedAt())
|
||||
.build();
|
||||
}
|
||||
|
||||
private ExtraDto toExtraDto(StaffEmployeeExtra extra) {
|
||||
if (extra == null) {
|
||||
return ExtraDto.builder().build();
|
||||
}
|
||||
return ExtraDto.builder()
|
||||
.education(extra.getEducation())
|
||||
.major(extra.getMajor())
|
||||
.school(extra.getSchool())
|
||||
.graduationDate(extra.getGraduationDate())
|
||||
.previousCompany(extra.getPreviousCompany())
|
||||
.workYears(extra.getWorkYears())
|
||||
.technicalLevel(extra.getTechnicalLevel())
|
||||
.salaryGrade(extra.getSalaryGrade())
|
||||
.contractType(extra.getContractType())
|
||||
.contractStart(extra.getContractStart())
|
||||
.contractEnd(extra.getContractEnd())
|
||||
.probationEnd(extra.getProbationEnd())
|
||||
.address(extra.getAddress())
|
||||
.remark(extra.getRemark())
|
||||
.build();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 更新
|
||||
// ------------------------------------------------------------------
|
||||
@Transactional
|
||||
public StaffDetailResponse update(Long id, StaffUpdateRequest req, AuthUser user) {
|
||||
StaffEmployee employee = getEmployee(id);
|
||||
boolean adminOrHr = user.isAdminOrHr();
|
||||
if (adminOrHr) {
|
||||
applyAdminUpdate(employee, req, user);
|
||||
} else {
|
||||
if (!Objects.equals(user.id(), employee.getId())) {
|
||||
throw BusinessException.forbidden("普通员工仅可修改本人的联系方式");
|
||||
}
|
||||
applyEmployeeSelfUpdate(employee, req);
|
||||
}
|
||||
employee.setUpdatedBy(user.id());
|
||||
employee = employeeRepository.save(employee);
|
||||
|
||||
if (req.getExtra() != null) {
|
||||
StaffEmployeeExtra extra = extraRepository.findByEmployeeId(id)
|
||||
.orElseGet(StaffEmployeeExtra::new);
|
||||
if (extra.getEmployeeId() == null) {
|
||||
extra.setEmployeeId(id);
|
||||
}
|
||||
if (adminOrHr) {
|
||||
applyExtra(extra, req.getExtra());
|
||||
} else if (req.getExtra().getAddress() != null) {
|
||||
extra.setAddress(req.getExtra().getAddress());
|
||||
}
|
||||
extraRepository.save(extra);
|
||||
}
|
||||
audit("UPDATE", id, user, null, null, null);
|
||||
return detail(id, user);
|
||||
}
|
||||
|
||||
private void applyAdminUpdate(StaffEmployee e, StaffUpdateRequest req, AuthUser user) {
|
||||
if (req.getDepartmentId() != null && !req.getDepartmentId().equals(e.getDepartmentId())) {
|
||||
StaffDepartmentCache dept = departmentRepository.findById(req.getDepartmentId())
|
||||
.orElseThrow(() -> BusinessException.badRequest("部门不存在"));
|
||||
changeLog(e.getId(), "DEPT_CHANGE",
|
||||
String.valueOf(e.getDepartmentId()), String.valueOf(req.getDepartmentId()), user, null);
|
||||
e.setDepartmentId(req.getDepartmentId());
|
||||
e.setDepartmentName(dept.getName());
|
||||
}
|
||||
if (req.getPosition() != null && !req.getPosition().equals(e.getPosition())) {
|
||||
changeLog(e.getId(), "POSITION_CHANGE", e.getPosition(), req.getPosition(), user, null);
|
||||
e.setPosition(req.getPosition());
|
||||
}
|
||||
if (req.getStatus() != null) {
|
||||
if (!ALL_STATUS.contains(req.getStatus())) {
|
||||
throw BusinessException.badRequest("员工状态不合法");
|
||||
}
|
||||
if (!req.getStatus().equals(e.getStatus())) {
|
||||
changeLog(e.getId(), "STATUS_CHANGE", e.getStatus(), req.getStatus(), user, null);
|
||||
e.setStatus(req.getStatus());
|
||||
}
|
||||
}
|
||||
if (req.getHireDate() != null) {
|
||||
e.setHireDate(req.getHireDate());
|
||||
}
|
||||
if (req.getPhone() != null) {
|
||||
e.setPhone(req.getPhone());
|
||||
}
|
||||
if (req.getEmail() != null) {
|
||||
e.setEmail(req.getEmail());
|
||||
}
|
||||
if (req.getEmergencyContact() != null) {
|
||||
e.setEmergencyContact(req.getEmergencyContact());
|
||||
}
|
||||
if (req.getEmergencyPhone() != null) {
|
||||
e.setEmergencyPhone(req.getEmergencyPhone());
|
||||
}
|
||||
if (req.getGender() != null) {
|
||||
e.setGender(req.getGender());
|
||||
}
|
||||
if (req.getExtra() != null && req.getExtra().getTechnicalLevel() != null) {
|
||||
StaffEmployeeExtra old = extraRepository.findByEmployeeId(e.getId()).orElse(null);
|
||||
String oldLevel = old == null ? null : old.getTechnicalLevel();
|
||||
if (!Objects.equals(oldLevel, req.getExtra().getTechnicalLevel())) {
|
||||
changeLog(e.getId(), "LEVEL_CHANGE", oldLevel,
|
||||
req.getExtra().getTechnicalLevel(), user, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void applyEmployeeSelfUpdate(StaffEmployee e, StaffUpdateRequest req) {
|
||||
if (req.getPhone() != null) {
|
||||
e.setPhone(req.getPhone());
|
||||
}
|
||||
if (req.getEmail() != null) {
|
||||
e.setEmail(req.getEmail());
|
||||
}
|
||||
if (req.getEmergencyContact() != null) {
|
||||
e.setEmergencyContact(req.getEmergencyContact());
|
||||
}
|
||||
if (req.getEmergencyPhone() != null) {
|
||||
e.setEmergencyPhone(req.getEmergencyPhone());
|
||||
}
|
||||
}
|
||||
|
||||
private void saveExtra(Long employeeId, ExtraDto dto) {
|
||||
StaffEmployeeExtra extra = new StaffEmployeeExtra();
|
||||
extra.setEmployeeId(employeeId);
|
||||
applyExtra(extra, dto);
|
||||
extraRepository.save(extra);
|
||||
}
|
||||
|
||||
private void applyExtra(StaffEmployeeExtra extra, ExtraDto dto) {
|
||||
if (dto == null) {
|
||||
return;
|
||||
}
|
||||
if (dto.getEducation() != null) {
|
||||
extra.setEducation(dto.getEducation());
|
||||
}
|
||||
if (dto.getMajor() != null) {
|
||||
extra.setMajor(dto.getMajor());
|
||||
}
|
||||
if (dto.getSchool() != null) {
|
||||
extra.setSchool(dto.getSchool());
|
||||
}
|
||||
if (dto.getGraduationDate() != null) {
|
||||
extra.setGraduationDate(dto.getGraduationDate());
|
||||
}
|
||||
if (dto.getPreviousCompany() != null) {
|
||||
extra.setPreviousCompany(dto.getPreviousCompany());
|
||||
}
|
||||
if (dto.getWorkYears() != null) {
|
||||
extra.setWorkYears(dto.getWorkYears());
|
||||
}
|
||||
if (dto.getTechnicalLevel() != null) {
|
||||
extra.setTechnicalLevel(dto.getTechnicalLevel());
|
||||
}
|
||||
if (dto.getSalaryGrade() != null) {
|
||||
extra.setSalaryGrade(dto.getSalaryGrade());
|
||||
}
|
||||
if (dto.getContractType() != null) {
|
||||
extra.setContractType(dto.getContractType());
|
||||
}
|
||||
if (dto.getContractStart() != null) {
|
||||
extra.setContractStart(dto.getContractStart());
|
||||
}
|
||||
if (dto.getContractEnd() != null) {
|
||||
extra.setContractEnd(dto.getContractEnd());
|
||||
}
|
||||
if (dto.getProbationEnd() != null) {
|
||||
extra.setProbationEnd(dto.getProbationEnd());
|
||||
}
|
||||
if (dto.getAddress() != null) {
|
||||
extra.setAddress(dto.getAddress());
|
||||
}
|
||||
if (dto.getRemark() != null) {
|
||||
extra.setRemark(dto.getRemark());
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 删除
|
||||
// ------------------------------------------------------------------
|
||||
@Transactional
|
||||
public Map<String, Object> delete(Long id, AuthUser user) {
|
||||
StaffEmployee employee = getEmployee(id);
|
||||
if ("inactive".equals(employee.getStatus())) {
|
||||
throw BusinessException.badRequest("该员工已删除,不可重复删除");
|
||||
}
|
||||
if (externalProcessClient.hasUnfinishedProcess(id)) {
|
||||
throw BusinessException.badRequest("该员工有关联未完结流程,无法删除");
|
||||
}
|
||||
String oldStatus = employee.getStatus();
|
||||
employee.setStatus("inactive");
|
||||
employee.setUpdatedBy(user.id());
|
||||
employeeRepository.save(employee);
|
||||
changeLog(id, "STATUS_CHANGE", oldStatus, "inactive", user, "员工离职/删除");
|
||||
audit("DELETE", id, user, "status", oldStatus, "inactive");
|
||||
|
||||
Map<String, Object> data = new LinkedHashMap<>();
|
||||
data.put("id", id);
|
||||
data.put("status", "inactive");
|
||||
data.put("updatedAt", employee.getUpdatedAt());
|
||||
return data;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public BatchDeleteResult batchDelete(List<Long> ids, AuthUser user) {
|
||||
int successCount = 0;
|
||||
List<BatchDeleteResult.FailedItem> failedList = new ArrayList<>();
|
||||
for (Long id : ids) {
|
||||
try {
|
||||
delete(id, user);
|
||||
successCount++;
|
||||
} catch (BusinessException ex) {
|
||||
failedList.add(BatchDeleteResult.FailedItem.builder()
|
||||
.id(id).reason(ex.getMessage()).build());
|
||||
}
|
||||
}
|
||||
return BatchDeleteResult.builder()
|
||||
.successCount(successCount)
|
||||
.failedList(failedList)
|
||||
.build();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 变更记录 / 操作日志
|
||||
// ------------------------------------------------------------------
|
||||
@Transactional(readOnly = true)
|
||||
public PageResult<ChangeLogItem> changeLogs(Long employeeId, String changeType, int page, int size) {
|
||||
getEmployee(employeeId);
|
||||
Specification<StaffChangeLog> spec = (root, query, cb) -> {
|
||||
List<Predicate> predicates = new ArrayList<>();
|
||||
predicates.add(cb.equal(root.get("employeeId"), employeeId));
|
||||
if (changeType != null && !changeType.isBlank()) {
|
||||
predicates.add(cb.equal(root.get("changeType"), changeType));
|
||||
}
|
||||
return cb.and(predicates.toArray(new Predicate[0]));
|
||||
};
|
||||
Pageable pageable = PageRequest.of(Math.max(0, page - 1), clampSize(size),
|
||||
Sort.by(Sort.Direction.DESC, "createdAt"));
|
||||
Page<StaffChangeLog> result = changeLogRepository.findAll(spec, pageable);
|
||||
List<ChangeLogItem> items = result.getContent().stream()
|
||||
.map(this::toChangeLogItem)
|
||||
.toList();
|
||||
return PageResult.of(result, items);
|
||||
}
|
||||
|
||||
private ChangeLogItem toChangeLogItem(StaffChangeLog log) {
|
||||
return ChangeLogItem.builder()
|
||||
.id(log.getId())
|
||||
.changeType(log.getChangeType())
|
||||
.oldValue(log.getOldValue())
|
||||
.newValue(log.getNewValue())
|
||||
.operatorId(log.getOperatorId())
|
||||
.operatorName(log.getOperatorName())
|
||||
.changeReason(log.getChangeReason())
|
||||
.createdAt(log.getCreatedAt())
|
||||
.build();
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public PageResult<AuditLogItem> auditLogs(String targetType, Long targetId, String operation,
|
||||
LocalDateTime startTime, LocalDateTime endTime,
|
||||
int page, int size) {
|
||||
Specification<StaffAuditLog> spec = (root, query, cb) -> {
|
||||
List<Predicate> predicates = new ArrayList<>();
|
||||
if (targetType != null && !targetType.isBlank()) {
|
||||
predicates.add(cb.equal(root.get("targetType"), targetType));
|
||||
}
|
||||
if (targetId != null) {
|
||||
predicates.add(cb.equal(root.get("targetId"), targetId));
|
||||
}
|
||||
if (operation != null && !operation.isBlank()) {
|
||||
predicates.add(cb.equal(root.get("operation"), operation));
|
||||
}
|
||||
if (startTime != null) {
|
||||
predicates.add(cb.greaterThanOrEqualTo(root.get("createdAt"), startTime));
|
||||
}
|
||||
if (endTime != null) {
|
||||
predicates.add(cb.lessThanOrEqualTo(root.get("createdAt"), endTime));
|
||||
}
|
||||
return cb.and(predicates.toArray(new Predicate[0]));
|
||||
};
|
||||
Pageable pageable = PageRequest.of(Math.max(0, page - 1), clampSize(size),
|
||||
Sort.by(Sort.Direction.DESC, "createdAt"));
|
||||
Page<StaffAuditLog> result = auditLogRepository.findAll(spec, pageable);
|
||||
List<AuditLogItem> items = result.getContent().stream()
|
||||
.map(this::toAuditLogItem)
|
||||
.toList();
|
||||
return PageResult.of(result, items);
|
||||
}
|
||||
|
||||
private AuditLogItem toAuditLogItem(StaffAuditLog log) {
|
||||
return AuditLogItem.builder()
|
||||
.id(log.getId())
|
||||
.targetType(log.getTargetType())
|
||||
.targetId(log.getTargetId())
|
||||
.operation(log.getOperation())
|
||||
.operatorId(log.getOperatorId())
|
||||
.operatorName(log.getOperatorName())
|
||||
.fieldName(log.getFieldName())
|
||||
.beforeValue(log.getBeforeValue())
|
||||
.afterValue(log.getAfterValue())
|
||||
.requestIp(log.getRequestIp())
|
||||
.createdAt(log.getCreatedAt())
|
||||
.build();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 工号查重 / 部门列表
|
||||
// ------------------------------------------------------------------
|
||||
@Transactional(readOnly = true)
|
||||
public CheckEmployeeNoResult checkEmployeeNo(String employeeNo, Long excludeId) {
|
||||
boolean exists = excludeId == null
|
||||
? employeeRepository.existsByEmployeeNo(employeeNo)
|
||||
: employeeRepository.existsByEmployeeNoAndIdNot(employeeNo, excludeId);
|
||||
return CheckEmployeeNoResult.builder().exists(exists).build();
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<DepartmentItem> departments() {
|
||||
return departmentRepository.findAllByOrderByIdAsc().stream()
|
||||
.map(d -> DepartmentItem.builder()
|
||||
.id(d.getId())
|
||||
.name(d.getName())
|
||||
.parentId(d.getParentId())
|
||||
.level(d.getLevel())
|
||||
.employeeCount((int) employeeRepository.countByDepartmentId(d.getId()))
|
||||
.build())
|
||||
.toList();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 私有工具方法
|
||||
// ------------------------------------------------------------------
|
||||
private StaffEmployee getEmployee(Long id) {
|
||||
return employeeRepository.findById(id)
|
||||
.orElseThrow(() -> BusinessException.notFound("员工不存在"));
|
||||
}
|
||||
|
||||
private void validateIdNumber(String idNumber) {
|
||||
if (!ID_NUMBER_PATTERN.matcher(idNumber).matches()) {
|
||||
throw BusinessException.badRequest("身份证号格式不正确");
|
||||
}
|
||||
}
|
||||
|
||||
private void changeLog(Long employeeId, String type, String oldValue, String newValue,
|
||||
AuthUser user, String reason) {
|
||||
StaffChangeLog log = new StaffChangeLog();
|
||||
log.setEmployeeId(employeeId);
|
||||
log.setChangeType(type);
|
||||
log.setOldValue(oldValue);
|
||||
log.setNewValue(newValue);
|
||||
log.setOperatorId(user.id());
|
||||
log.setOperatorName(user.username());
|
||||
log.setChangeReason(reason);
|
||||
changeLogRepository.save(log);
|
||||
}
|
||||
|
||||
private void audit(String operation, Long targetId, AuthUser user,
|
||||
String fieldName, String beforeValue, String afterValue) {
|
||||
StaffAuditLog log = new StaffAuditLog();
|
||||
log.setTargetType("EMPLOYEE");
|
||||
log.setTargetId(targetId);
|
||||
log.setOperation(operation);
|
||||
log.setOperatorId(user.id());
|
||||
log.setOperatorName(user.username());
|
||||
log.setFieldName(fieldName);
|
||||
log.setBeforeValue(beforeValue);
|
||||
log.setAfterValue(afterValue);
|
||||
ServletRequestAttributes attrs = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
|
||||
log.setRequestIp(attrs != null ? attrs.getRequest().getRemoteAddr() : null);
|
||||
log.setUserAgent(attrs != null ? attrs.getRequest().getHeader("User-Agent") : null);
|
||||
auditLogRepository.save(log);
|
||||
}
|
||||
|
||||
private Sort parseSort(String sort) {
|
||||
if (sort == null || sort.isBlank()) {
|
||||
return Sort.by(Sort.Direction.DESC, "createdAt");
|
||||
}
|
||||
List<Sort.Order> orders = new ArrayList<>();
|
||||
for (String part : sort.split(";")) {
|
||||
if (part.isBlank()) {
|
||||
continue;
|
||||
}
|
||||
String[] kv = part.trim().split(",");
|
||||
String field = kv[0].trim();
|
||||
Sort.Direction direction = kv.length > 1 && "asc".equalsIgnoreCase(kv[1].trim())
|
||||
? Sort.Direction.ASC
|
||||
: Sort.Direction.DESC;
|
||||
orders.add(new Sort.Order(direction, field));
|
||||
}
|
||||
return orders.isEmpty() ? Sort.by(Sort.Direction.DESC, "createdAt") : Sort.by(orders);
|
||||
}
|
||||
|
||||
private int clampSize(int size) {
|
||||
return Math.max(1, Math.min(size, 100));
|
||||
}
|
||||
}
|
||||
(见仓库,已提交)
|
||||
@ -1,101 +1 @@
|
||||
-- 员工管理模块 MySQL 8.0 DDL(生产库手动执行)
|
||||
CREATE TABLE IF NOT EXISTS staff_employee (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
||||
employee_no VARCHAR(32) NOT NULL COMMENT '工号,唯一标识',
|
||||
name VARCHAR(64) NOT NULL COMMENT '员工姓名',
|
||||
id_number VARCHAR(64) NOT NULL COMMENT '身份证号,应用层加密存储',
|
||||
gender TINYINT DEFAULT 0 COMMENT '性别:0-未知,1-男,2-女',
|
||||
department_id BIGINT NOT NULL COMMENT '所属部门ID',
|
||||
department_name VARCHAR(128) DEFAULT '' COMMENT '部门名称(冗余,加快查询)',
|
||||
position VARCHAR(128) NOT NULL COMMENT '岗位名称',
|
||||
hire_date DATE NOT NULL COMMENT '入职日期',
|
||||
phone VARCHAR(20) DEFAULT NULL COMMENT '联系电话',
|
||||
email VARCHAR(128) DEFAULT NULL COMMENT '邮箱',
|
||||
emergency_contact VARCHAR(64) DEFAULT NULL COMMENT '紧急联系人姓名',
|
||||
emergency_phone VARCHAR(20) DEFAULT NULL COMMENT '紧急联系人电话',
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'active' COMMENT '状态:active-在职,inactive-离职,probation-试用期',
|
||||
created_by BIGINT DEFAULT NULL COMMENT '创建人ID',
|
||||
updated_by BIGINT DEFAULT NULL COMMENT '最后更新人ID',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_employee_no (employee_no),
|
||||
UNIQUE KEY uk_id_number (id_number),
|
||||
KEY idx_name (name),
|
||||
KEY idx_department_id (department_id),
|
||||
KEY idx_status (status),
|
||||
KEY idx_hire_date (hire_date),
|
||||
KEY idx_created_at (created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='员工主表';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS staff_employee_extra (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
||||
employee_id BIGINT NOT NULL COMMENT '员工ID',
|
||||
education VARCHAR(32) DEFAULT NULL COMMENT '学历',
|
||||
major VARCHAR(128) DEFAULT NULL COMMENT '专业',
|
||||
school VARCHAR(128) DEFAULT NULL COMMENT '毕业院校',
|
||||
graduation_date DATE DEFAULT NULL COMMENT '毕业时间',
|
||||
previous_company VARCHAR(128) DEFAULT NULL COMMENT '前工作单位',
|
||||
work_years INT DEFAULT 0 COMMENT '总工作年限',
|
||||
technical_level VARCHAR(32) DEFAULT NULL COMMENT '技术级别,仅 Admin/HR 可见',
|
||||
salary_grade VARCHAR(32) DEFAULT NULL COMMENT '薪资等级,仅 Admin/HR 可见',
|
||||
contract_type VARCHAR(32) DEFAULT NULL COMMENT '合同类型',
|
||||
contract_start DATE DEFAULT NULL COMMENT '合同开始日期',
|
||||
contract_end DATE DEFAULT NULL COMMENT '合同结束日期',
|
||||
probation_end DATE DEFAULT NULL COMMENT '试用期截止日',
|
||||
address VARCHAR(256) DEFAULT NULL COMMENT '现住址',
|
||||
remark TEXT DEFAULT NULL COMMENT '备注',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_employee_id (employee_id),
|
||||
KEY idx_technical_level (technical_level),
|
||||
KEY idx_contract_end (contract_end)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='员工扩展信息表';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS staff_change_log (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
||||
employee_id BIGINT NOT NULL COMMENT '员工ID',
|
||||
change_type VARCHAR(32) NOT NULL COMMENT '变更类型',
|
||||
old_value VARCHAR(256) DEFAULT NULL COMMENT '原值',
|
||||
new_value VARCHAR(256) DEFAULT NULL COMMENT '新值',
|
||||
operator_id BIGINT NOT NULL COMMENT '操作人ID',
|
||||
operator_name VARCHAR(64) DEFAULT NULL COMMENT '操作人姓名',
|
||||
change_reason VARCHAR(512) DEFAULT NULL COMMENT '变更原因',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '变更时间',
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_employee_id (employee_id),
|
||||
KEY idx_change_type (change_type),
|
||||
KEY idx_created_at (created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='员工变更记录表';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS staff_audit_log (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
||||
target_type VARCHAR(32) NOT NULL COMMENT '操作目标类型',
|
||||
target_id BIGINT NOT NULL COMMENT '操作目标ID',
|
||||
operation VARCHAR(32) NOT NULL COMMENT '操作类型:CREATE/UPDATE/DELETE/VIEW',
|
||||
operator_id BIGINT NOT NULL COMMENT '操作人ID',
|
||||
operator_name VARCHAR(64) DEFAULT '' COMMENT '操作人姓名',
|
||||
field_name VARCHAR(64) DEFAULT NULL COMMENT '变更字段名',
|
||||
before_value TEXT DEFAULT NULL COMMENT '变更前值',
|
||||
after_value TEXT DEFAULT NULL COMMENT '变更后值',
|
||||
request_ip VARCHAR(64) DEFAULT NULL COMMENT '请求IP',
|
||||
user_agent VARCHAR(512) DEFAULT NULL COMMENT '浏览器UA',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '操作时间',
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_target (target_type, target_id),
|
||||
KEY idx_operator_id (operator_id),
|
||||
KEY idx_created_at (created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='操作审计日志表';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS staff_department_cache (
|
||||
id BIGINT NOT NULL COMMENT '部门ID(来自 org-mgr)',
|
||||
name VARCHAR(128) NOT NULL COMMENT '部门名称',
|
||||
parent_id BIGINT DEFAULT NULL COMMENT '上级部门ID',
|
||||
level INT DEFAULT 0 COMMENT '层级深度',
|
||||
employee_count INT DEFAULT 0 COMMENT '部门人数',
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'active' COMMENT '状态',
|
||||
sync_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '最后同步时间',
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_parent_id (parent_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='部门信息本地缓存表';
|
||||
(见仓库,已提交)
|
||||
@ -1,199 +1 @@
|
||||
package com.hr.staff;
|
||||
|
||||
import com.hr.staff.repository.StaffEmployeeRepository;
|
||||
import com.hr.staff.security.AuthUser;
|
||||
import com.hr.staff.security.JwtService;
|
||||
import org.junit.jupiter.api.MethodOrderer;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.TestMethodOrder;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
@SpringBootTest
|
||||
@AutoConfigureMockMvc
|
||||
@ActiveProfiles("dev")
|
||||
@TestMethodOrder(MethodOrderer.MethodName.class)
|
||||
@Transactional
|
||||
class StaffApiIntegrationTest {
|
||||
|
||||
@Autowired
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@Autowired
|
||||
private JwtService jwtService;
|
||||
|
||||
@Autowired
|
||||
private StaffEmployeeRepository employeeRepository;
|
||||
|
||||
private String adminToken() {
|
||||
return "Bearer " + jwtService.generateToken(new AuthUser(1L, "admin", Set.of("ADMIN"), 1003L));
|
||||
}
|
||||
|
||||
private String hrToken() {
|
||||
return "Bearer " + jwtService.generateToken(new AuthUser(2L, "hr", Set.of("HR"), 1003L));
|
||||
}
|
||||
|
||||
private String employeeToken(Long departmentId) {
|
||||
return "Bearer " + jwtService.generateToken(new AuthUser(10L, "employee", Set.of("EMPLOYEE"), departmentId));
|
||||
}
|
||||
|
||||
private String createBody(String employeeNo, String name, String idNumber, Long departmentId) {
|
||||
return """
|
||||
{
|
||||
"employeeNo": "%s",
|
||||
"name": "%s",
|
||||
"idNumber": "%s",
|
||||
"gender": 1,
|
||||
"departmentId": %d,
|
||||
"position": "Java开发工程师",
|
||||
"hireDate": "2024-01-10",
|
||||
"phone": "13800138000",
|
||||
"email": "zhangsan@example.com",
|
||||
"emergencyContact": "张父",
|
||||
"emergencyPhone": "13900139000",
|
||||
"status": "active",
|
||||
"extra": {
|
||||
"education": "本科",
|
||||
"technicalLevel": "P6",
|
||||
"salaryGrade": "S3",
|
||||
"address": "北京市朝阳区"
|
||||
}
|
||||
}
|
||||
""".formatted(employeeNo, name, idNumber, departmentId);
|
||||
}
|
||||
|
||||
private void createEmployee(String employeeNo, String name, String idNumber, Long departmentId) throws Exception {
|
||||
mockMvc.perform(post("/api/v1/staff")
|
||||
.header("Authorization", adminToken())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(createBody(employeeNo, name, idNumber, departmentId)))
|
||||
.andExpect(status().isCreated())
|
||||
.andExpect(jsonPath("$.code").value(201));
|
||||
}
|
||||
|
||||
private Long employeeId(String employeeNo) {
|
||||
return employeeRepository.findByEmployeeNo(employeeNo).orElseThrow().getId();
|
||||
}
|
||||
|
||||
@Test
|
||||
void test01_createListAndDetail() throws Exception {
|
||||
createEmployee("EMP20240001", "张三", "110101199001011234", 1001L);
|
||||
mockMvc.perform(get("/api/v1/staff").header("Authorization", adminToken()).param("page", "1").param("size", "20"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(200))
|
||||
.andExpect(jsonPath("$.data.totalElements").value(1))
|
||||
.andExpect(jsonPath("$.data.content[0].employeeNo").value("EMP20240001"))
|
||||
.andExpect(jsonPath("$.data.content[0].phone").value("138****8000"));
|
||||
Long id = employeeId("EMP20240001");
|
||||
mockMvc.perform(get("/api/v1/staff/" + id).header("Authorization", adminToken()))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.data.idNumber").value("110************234"))
|
||||
.andExpect(jsonPath("$.data.phone").value("138****8000"))
|
||||
.andExpect(jsonPath("$.data.emergencyPhone").value("13900139000"))
|
||||
.andExpect(jsonPath("$.data.extra.technicalLevel").value("P6"))
|
||||
.andExpect(jsonPath("$.data.extra.salaryGrade").value("S3"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void test02_duplicateEmployeeNoReturns409() throws Exception {
|
||||
createEmployee("EMP20240002", "李四", "110101199001011235", 1001L);
|
||||
mockMvc.perform(post("/api/v1/staff").header("Authorization", adminToken()).contentType(MediaType.APPLICATION_JSON)
|
||||
.content(createBody("EMP20240002", "王五", "110101199001011236", 1002L)))
|
||||
.andExpect(status().isConflict())
|
||||
.andExpect(jsonPath("$.code").value(409));
|
||||
}
|
||||
|
||||
@Test
|
||||
void test03_employeeDataPermissionAndMasking() throws Exception {
|
||||
createEmployee("EMP20240003", "赵六", "110101199001011237", 1001L);
|
||||
Long id = employeeId("EMP20240003");
|
||||
mockMvc.perform(get("/api/v1/staff/" + id).header("Authorization", employeeToken(1001L)))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.data.idNumber").value("110************237"))
|
||||
.andExpect(jsonPath("$.data.phone").value("138****8000"))
|
||||
.andExpect(jsonPath("$.data.emergencyPhone").value("139****9000"))
|
||||
.andExpect(jsonPath("$.data.extra.technicalLevel").doesNotExist());
|
||||
mockMvc.perform(get("/api/v1/staff/" + id).header("Authorization", employeeToken(1002L)))
|
||||
.andExpect(status().isForbidden())
|
||||
.andExpect(jsonPath("$.code").value(403));
|
||||
}
|
||||
|
||||
@Test
|
||||
void test04_updateGeneratesChangeLog() throws Exception {
|
||||
createEmployee("EMP20240004", "孙七", "110101199001011238", 1001L);
|
||||
Long id = employeeId("EMP20240004");
|
||||
mockMvc.perform(put("/api/v1/staff/" + id).header("Authorization", adminToken()).contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{"position": "高级Java开发工程师", "extra": {"technicalLevel": "P7"}}
|
||||
"""))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.data.position").value("高级Java开发工程师"));
|
||||
mockMvc.perform(get("/api/v1/staff/" + id + "/change-logs").header("Authorization", adminToken()))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.data.totalElements").value(2))
|
||||
.andExpect(jsonPath("$.data.content[0].changeType").value("LEVEL_CHANGE"))
|
||||
.andExpect(jsonPath("$.data.content[1].changeType").value("POSITION_CHANGE"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void test05_checkEmployeeNo() throws Exception {
|
||||
createEmployee("EMP20240005", "周八", "110101199001011239", 1001L);
|
||||
mockMvc.perform(get("/api/v1/staff/check/employee-no").header("Authorization", adminToken()).param("employeeNo", "EMP20240005"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.data.exists").value(true));
|
||||
mockMvc.perform(get("/api/v1/staff/check/employee-no").header("Authorization", adminToken()).param("employeeNo", "NOT_EXIST"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.data.exists").value(false));
|
||||
}
|
||||
|
||||
@Test
|
||||
void test06_logicalDeleteAndRepeatProtection() throws Exception {
|
||||
createEmployee("EMP20240006", "吴九", "110101199001011240", 1001L);
|
||||
Long id = employeeId("EMP20240006");
|
||||
mockMvc.perform(delete("/api/v1/staff/" + id).header("Authorization", adminToken()))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.data.status").value("inactive"));
|
||||
mockMvc.perform(delete("/api/v1/staff/" + id).header("Authorization", adminToken()))
|
||||
.andExpect(status().isBadRequest())
|
||||
.andExpect(jsonPath("$.code").value(400));
|
||||
}
|
||||
|
||||
@Test
|
||||
void test07_batchDelete() throws Exception {
|
||||
createEmployee("EMP20240007", "郑十", "110101199001011241", 1001L);
|
||||
createEmployee("EMP20240008", "钱一", "110101199001011242", 1001L);
|
||||
Long id1 = employeeId("EMP20240007");
|
||||
Long id2 = employeeId("EMP20240008");
|
||||
mockMvc.perform(post("/api/v1/staff/batch-delete").header("Authorization", adminToken()).contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"ids\": [" + id1 + ", " + id2 + "]}"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.data.successCount").value(2))
|
||||
.andExpect(jsonPath("$.data.failedList.length()").value(0));
|
||||
}
|
||||
|
||||
@Test
|
||||
void test08_auditLogsAndDepartments() throws Exception {
|
||||
createEmployee("EMP20240009", "冯二", "110101199001011243", 1001L);
|
||||
mockMvc.perform(get("/api/v1/staff/audit-logs").header("Authorization", adminToken()).param("operation", "CREATE"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.data.totalElements").value(1))
|
||||
.andExpect(jsonPath("$.data.content[0].operation").value("CREATE"));
|
||||
mockMvc.perform(get("/api/v1/staff/departments").header("Authorization", adminToken()))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.data.length()").value(3));
|
||||
}
|
||||
}
|
||||
(见仓库,已提交)
|
||||
Loading…
x
Reference in New Issue
Block a user