diff --git a/src/main/java/com/hr/staff/common/GlobalExceptionHandler.java b/src/main/java/com/hr/staff/common/GlobalExceptionHandler.java index 4d84ba4..062cc72 100644 --- a/src/main/java/com/hr/staff/common/GlobalExceptionHandler.java +++ b/src/main/java/com/hr/staff/common/GlobalExceptionHandler.java @@ -1 +1,68 @@ -(见仓库,已提交) \ No newline at end of file +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> 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> 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> handleUnreadable(HttpMessageNotReadableException e) { + return ResponseEntity.badRequest().body(ApiResponse.of(400, "请求参数格式错误", null)); + } + + @ExceptionHandler(DataIntegrityViolationException.class) + public ResponseEntity> handleConflict(DataIntegrityViolationException e) { + return ResponseEntity.status(HttpStatus.CONFLICT) + .body(ApiResponse.of(409, "数据冲突,请检查工号或身份证号是否重复", null)); + } + + @ExceptionHandler(AccessDeniedException.class) + public ResponseEntity> handleAccessDenied(AccessDeniedException e) { + return ResponseEntity.status(HttpStatus.FORBIDDEN) + .body(ApiResponse.of(403, "无权限访问该资源", null)); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity> 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; + }; + } +} diff --git a/src/main/java/com/hr/staff/common/MaskUtil.java b/src/main/java/com/hr/staff/common/MaskUtil.java index 4d84ba4..536f7c3 100644 --- a/src/main/java/com/hr/staff/common/MaskUtil.java +++ b/src/main/java/com/hr/staff/common/MaskUtil.java @@ -1 +1,38 @@ -(见仓库,已提交) \ No newline at end of file +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); + } +} diff --git a/src/main/java/com/hr/staff/controller/StaffController.java b/src/main/java/com/hr/staff/controller/StaffController.java index 4d84ba4..79c564d 100644 --- a/src/main/java/com/hr/staff/controller/StaffController.java +++ b/src/main/java/com/hr/staff/controller/StaffController.java @@ -1 +1,133 @@ -(见仓库,已提交) \ No newline at end of file +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> 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> 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 detail(@PathVariable Long id) { + return ApiResponse.ok(staffService.detail(id, SecurityUtils.currentUser())); + } + + @PutMapping("/{id}") + public ApiResponse 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> delete(@PathVariable Long id) { + return ApiResponse.ok(staffService.delete(id, SecurityUtils.currentUser())); + } + + @PostMapping("/batch-delete") + @PreAuthorize("hasAnyRole('ADMIN','HR')") + public ApiResponse batchDelete(@Valid @RequestBody BatchDeleteRequest request) { + return ApiResponse.ok(staffService.batchDelete(request.getIds(), SecurityUtils.currentUser())); + } + + @GetMapping("/{id}/change-logs") + @PreAuthorize("hasAnyRole('ADMIN','HR')") + public ApiResponse> 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> 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 checkEmployeeNo( + @RequestParam String employeeNo, + @RequestParam(required = false) Long excludeId) { + return ApiResponse.ok(staffService.checkEmployeeNo(employeeNo, excludeId)); + } + + @GetMapping("/departments") + public ApiResponse> departments() { + return ApiResponse.ok(staffService.departments()); + } +} diff --git a/src/main/java/com/hr/staff/security/AesCipher.java b/src/main/java/com/hr/staff/security/AesCipher.java index 4d84ba4..78e0fa5 100644 --- a/src/main/java/com/hr/staff/security/AesCipher.java +++ b/src/main/java/com/hr/staff/security/AesCipher.java @@ -1 +1,60 @@ -(见仓库,已提交) \ No newline at end of file +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); + } + } +} diff --git a/src/main/java/com/hr/staff/security/JwtService.java b/src/main/java/com/hr/staff/security/JwtService.java index 4d84ba4..f5fb520 100644 --- a/src/main/java/com/hr/staff/security/JwtService.java +++ b/src/main/java/com/hr/staff/security/JwtService.java @@ -1 +1,59 @@ -(见仓库,已提交) \ No newline at end of file +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 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 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()); + } +} diff --git a/src/main/java/com/hr/staff/service/StaffService.java b/src/main/java/com/hr/staff/service/StaffService.java index 4d84ba4..28f0950 100644 --- a/src/main/java/com/hr/staff/service/StaffService.java +++ b/src/main/java/com/hr/staff/service/StaffService.java @@ -1 +1,638 @@ -(见仓库,已提交) \ No newline at end of file +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 CREATE_STATUS = Set.of("active", "probation"); + private static final Set 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 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 spec = buildListSpec(keyword, departmentId, position, status, + hireDateStart, hireDateEnd, user); + Pageable pageable = PageRequest.of(Math.max(0, page - 1), clampSize(size), parseSort(sort)); + Page result = employeeRepository.findAll(spec, pageable); + List items = result.getContent().stream().map(this::toListItem).toList(); + return PageResult.of(result, items); + } + + private Specification buildListSpec(String keyword, Long departmentId, + String position, String status, + LocalDate hireDateStart, LocalDate hireDateEnd, + AuthUser user) { + return (root, query, cb) -> { + List 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 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 data = new LinkedHashMap<>(); + data.put("id", id); + data.put("status", "inactive"); + data.put("updatedAt", employee.getUpdatedAt()); + return data; + } + + @Transactional + public BatchDeleteResult batchDelete(List ids, AuthUser user) { + int successCount = 0; + List 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 changeLogs(Long employeeId, String changeType, int page, int size) { + getEmployee(employeeId); + Specification spec = (root, query, cb) -> { + List 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 result = changeLogRepository.findAll(spec, pageable); + List 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 auditLogs(String targetType, Long targetId, String operation, + LocalDateTime startTime, LocalDateTime endTime, + int page, int size) { + Specification spec = (root, query, cb) -> { + List 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 result = auditLogRepository.findAll(spec, pageable); + List 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 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 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)); + } +} diff --git a/src/main/resources/db/schema.sql b/src/main/resources/db/schema.sql index 4d84ba4..a168f90 100644 --- a/src/main/resources/db/schema.sql +++ b/src/main/resources/db/schema.sql @@ -1 +1,101 @@ -(见仓库,已提交) \ No newline at end of file +-- 员工管理模块 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='部门信息本地缓存表'; diff --git a/src/test/java/com/hr/staff/StaffApiIntegrationTest.java b/src/test/java/com/hr/staff/StaffApiIntegrationTest.java index 4d84ba4..399d2f6 100644 --- a/src/test/java/com/hr/staff/StaffApiIntegrationTest.java +++ b/src/test/java/com/hr/staff/StaffApiIntegrationTest.java @@ -1 +1,199 @@ -(见仓库,已提交) \ No newline at end of file +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)); + } +}