From 0b5e437650588cbca72e5525ca04d1514657485e Mon Sep 17 00:00:00 2001 From: Pipeline Agent Date: Sat, 15 Aug 2026 01:20:09 +0800 Subject: [PATCH] =?UTF-8?q?develop:=20=E5=BC=80=E5=8F=91=E5=91=98=E5=B7=A5?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E6=A8=A1=E5=9D=97=EF=BC=88=E7=AB=AF=E5=88=B0?= =?UTF-8?q?=E7=AB=AF=E9=AA=8C=E8=AF=81=EF=BC=89=E9=87=8D=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 64 +- pom.xml | 119 +++- .../java/com/hr/staff/common/ApiResponse.java | 28 +- .../staff/common/GlobalExceptionHandler.java | 57 +- .../java/com/hr/staff/common/MaskUtil.java | 39 +- .../java/com/hr/staff/common/PageResult.java | 26 +- .../com/hr/staff/config/SecurityConfig.java | 50 +- .../hr/staff/controller/StaffController.java | 134 +++- .../com/hr/staff/entity/StaffAuditLog.java | 70 +- .../com/hr/staff/entity/StaffChangeLog.java | 61 +- .../hr/staff/entity/StaffDepartmentCache.java | 59 +- .../com/hr/staff/entity/StaffEmployee.java | 102 ++- .../hr/staff/entity/StaffEmployeeExtra.java | 98 ++- .../repository/StaffEmployeeRepository.java | 26 +- .../java/com/hr/staff/security/AesCipher.java | 61 +- .../com/hr/staff/service/StaffService.java | 639 +++++++++++++++++- src/main/resources/application-dev.yml | 18 +- src/main/resources/application.yml | 2 - src/main/resources/db/schema.sql | 102 ++- .../com/hr/staff/StaffApiIntegrationTest.java | 261 ++++++- 20 files changed, 1941 insertions(+), 75 deletions(-) diff --git a/README.md b/README.md index b996893..a63d54f 100644 --- a/README.md +++ b/README.md @@ -1,38 +1,48 @@ # staff-mgr 员工管理模块 -Web 版本人事系统的员工管理后端服务,提供员工 CRUD、敏感信息脱敏、变更记录、操作审计日志、工号查重及部门缓存接口。 +员工管理模块,提供员工(staff)增删改查 REST 接口。 ## 技术栈 -- Spring Boot 3.2 / Spring Data JPA / Spring Security + JWT -- MySQL 8.0(生产)/ H2(本地开发与测试) -- Maven +- Java 17 / Spring Boot 3.2 +- Spring Data JPA +- MySQL 8.0(生产)/ H2(本地开发 & 测试) +- Spring Security + JWT(对接 auth-mgr 的 Bearer Token) +- SpringDoc OpenAPI -## 接口 -基础路径:`/api/v1/staff`,除 Swagger 与 H2 控制台外均需 `Authorization: Bearer `。 - -| 方法 | 路径 | 说明 | -|------|------|------| -| POST | `/api/v1/staff` | 创建员工 | -| GET | `/api/v1/staff` | 分页列表查询 | -| GET | `/api/v1/staff/{id}` | 员工详情(按角色脱敏/数据权限) | -| PUT | `/api/v1/staff/{id}` | 部分更新员工 | -| DELETE | `/api/v1/staff/{id}` | 逻辑删除员工 | -| POST | `/api/v1/staff/batch-delete` | 批量删除 | -| GET | `/api/v1/staff/{id}/change-logs` | 变更记录 | -| GET | `/api/v1/staff/audit-logs` | 操作日志(Admin 专属) | -| GET | `/api/v1/staff/check/employee-no` | 工号查重 | -| GET | `/api/v1/staff/departments` | 部门缓存列表 | - -## 本地运行 +## 快速开始 ```bash -# 开发环境(H2 内存库) +# 本地开发(H2 内存库,无需外部依赖) mvn spring-boot:run -# 生产环境(MySQL) +# 生产(MySQL) mvn spring-boot:run -Dspring-boot.run.profiles=mysql ``` -## 测试 -```bash -mvn test -``` +启动后: +- API 文档: http://localhost:8080/swagger-ui.html +- H2 控制台(仅 dev): http://localhost:8080/h2-console + +## 接口一览 +基础路径 `/api/v1/staff`,所有接口需携带 `Authorization: Bearer `。 + +| 方法 | 路径 | 说明 | 权限 | +|------|------|------|------| +| POST | `/api/v1/staff` | 创建员工 | Admin, HR | +| GET | `/api/v1/staff` | 分页查询员工列表 | Admin, HR, Employee(本部门) | +| GET | `/api/v1/staff/{id}` | 查询员工详情 | Admin, HR, Employee(本部门) | +| PUT | `/api/v1/staff/{id}` | 更新员工信息 | Admin, HR / Employee(本人部分字段) | +| DELETE | `/api/v1/staff/{id}` | 逻辑删除员工 | Admin, HR | +| POST | `/api/v1/staff/batch-delete` | 批量删除 | Admin, HR | +| GET | `/api/v1/staff/{id}/change-logs` | 变更记录 | Admin, HR | +| GET | `/api/v1/staff/audit-logs` | 操作日志 | Admin | +| GET | `/api/v1/staff/check/employee-no` | 工号查重 | Admin, HR | +| GET | `/api/v1/staff/departments` | 部门缓存列表 | 所有已认证用户 | + +## 数据脱敏规则 +- 身份证号:所有人统一脱敏,保留前 3 位与后 3 位。 +- 手机号:保留前 3 位与后 4 位,Admin/HR 与 Employee(本部门) 脱敏,跨部门不返回。 +- 紧急联系人电话:Admin/HR 明文,Employee(本部门) 脱敏,跨部门不返回。 +- 技术级别/薪资等级:Admin/HR 明文,Employee 一律 null。 + +## 数据库 +生产库 DDL 见 `src/main/resources/db/schema.sql`。 diff --git a/pom.xml b/pom.xml index 98d552e..f14c402 100644 --- a/pom.xml +++ b/pom.xml @@ -1 +1,118 @@ -Maven 构建配置 \ No newline at end of file + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.2.5 + + + + com.hr + staff-mgr + 0.1.0-SNAPSHOT + staff-mgr + 员工管理模块 - staff CRUD REST API + + + 17 + 0.12.6 + 2.5.0 + + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-data-jpa + + + org.springframework.boot + spring-boot-starter-validation + + + org.springframework.boot + spring-boot-starter-security + + + + + io.jsonwebtoken + jjwt-api + ${jjwt.version} + + + io.jsonwebtoken + jjwt-impl + ${jjwt.version} + runtime + + + io.jsonwebtoken + jjwt-jackson + ${jjwt.version} + runtime + + + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + ${springdoc.version} + + + + + com.mysql + mysql-connector-j + runtime + + + com.h2database + h2 + runtime + + + + + org.projectlombok + lombok + true + + + + + org.springframework.boot + spring-boot-starter-test + test + + + org.springframework.security + spring-security-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + org.projectlombok + lombok + + + + + + + diff --git a/src/main/java/com/hr/staff/common/ApiResponse.java b/src/main/java/com/hr/staff/common/ApiResponse.java index 16be17a..d7057ac 100644 --- a/src/main/java/com/hr/staff/common/ApiResponse.java +++ b/src/main/java/com/hr/staff/common/ApiResponse.java @@ -1 +1,27 @@ -统一响应结构 \ No newline at end of file +package com.hr.staff.common; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 统一 API 响应结构。 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class ApiResponse { + + private int code; + private String message; + private T data; + private long timestamp = System.currentTimeMillis(); + + public static ApiResponse ok(T data) { + return new ApiResponse<>(200, "success", data, System.currentTimeMillis()); + } + + public static ApiResponse of(int code, String message, T data) { + return new ApiResponse<>(code, message, data, System.currentTimeMillis()); + } +} diff --git a/src/main/java/com/hr/staff/common/GlobalExceptionHandler.java b/src/main/java/com/hr/staff/common/GlobalExceptionHandler.java index 6f017cf..062cc72 100644 --- a/src/main/java/com/hr/staff/common/GlobalExceptionHandler.java +++ b/src/main/java/com/hr/staff/common/GlobalExceptionHandler.java @@ -1,49 +1,68 @@ package com.hr.staff.common; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; +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 { - private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class); - @ExceptionHandler(BusinessException.class) public ResponseEntity> handleBusiness(BusinessException e) { - HttpStatus status = HttpStatus.resolve(e.getCode()); - if (status == null) { - status = HttpStatus.INTERNAL_SERVER_ERROR; - } - return ResponseEntity.status(status).body(ApiResponse.error(e.getCode(), e.getMessage())); + 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) { - String message = e.getBindingResult().getFieldErrors().stream() - .map(fe -> fe.getField() + ": " + fe.getDefaultMessage()) - .findFirst() - .orElse("请求参数错误"); - return ResponseEntity.badRequest().body(ApiResponse.error(400, message)); + 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.error(403, "无权限")); + return ResponseEntity.status(HttpStatus.FORBIDDEN) + .body(ApiResponse.of(403, "无权限访问该资源", null)); } @ExceptionHandler(Exception.class) - public ResponseEntity> handleGeneric(Exception e) { - log.error("未处理异常", e); + public ResponseEntity> handleOther(Exception e) { return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) - .body(ApiResponse.error(500, "服务器内部错误")); + .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 2242d31..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/common/PageResult.java b/src/main/java/com/hr/staff/common/PageResult.java index 3403fb5..0750ccc 100644 --- a/src/main/java/com/hr/staff/common/PageResult.java +++ b/src/main/java/com/hr/staff/common/PageResult.java @@ -1,14 +1,18 @@ package com.hr.staff.common; +import lombok.AllArgsConstructor; import lombok.Data; +import lombok.NoArgsConstructor; import org.springframework.data.domain.Page; import java.util.List; /** - * 分页结果封装。 + * 分页结果封装,字段命名与设计文档保持一致。 */ @Data +@NoArgsConstructor +@AllArgsConstructor public class PageResult { private List content; @@ -19,15 +23,15 @@ public class PageResult { private boolean first; private boolean last; - public static PageResult of(Page page) { - PageResult result = new PageResult<>(); - result.setContent(page.getContent()); - result.setTotalElements(page.getTotalElements()); - result.setTotalPages(page.getTotalPages()); - result.setNumber(page.getNumber()); - result.setSize(page.getSize()); - result.setFirst(page.isFirst()); - result.setLast(page.isLast()); - return result; + public static PageResult of(Page page, List content) { + return new PageResult<>( + content, + page.getTotalElements(), + page.getTotalPages(), + page.getNumber(), + page.getSize(), + page.isFirst(), + page.isLast() + ); } } diff --git a/src/main/java/com/hr/staff/config/SecurityConfig.java b/src/main/java/com/hr/staff/config/SecurityConfig.java index c9d62f0..79e9ba2 100644 --- a/src/main/java/com/hr/staff/config/SecurityConfig.java +++ b/src/main/java/com/hr/staff/config/SecurityConfig.java @@ -1 +1,49 @@ -Spring Security 配置 \ No newline at end of file +package com.hr.staff.config; + +import com.hr.staff.security.JwtService; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +/** + * 无状态 JWT 安全配置。除文档与开发控制台外,其余接口均需认证。 + */ +@Configuration +@EnableWebSecurity +@EnableMethodSecurity +public class SecurityConfig { + + private final JwtService jwtService; + + public SecurityConfig(JwtService jwtService) { + this.jwtService = jwtService; + } + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http.csrf(AbstractHttpConfigurer::disable) + .cors(Customizer.withDefaults()) + .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .headers(h -> h.frameOptions(f -> f.disable())) + .authorizeHttpRequests(auth -> auth + .requestMatchers( + "/swagger-ui.html", + "/swagger-ui/**", + "/v3/api-docs/**", + "/h2-console/**", + "/actuator/**" + ).permitAll() + .anyRequest().authenticated() + ) + .addFilterBefore(new JwtAuthenticationFilter(jwtService), + UsernamePasswordAuthenticationFilter.class); + return http.build(); + } +} diff --git a/src/main/java/com/hr/staff/controller/StaffController.java b/src/main/java/com/hr/staff/controller/StaffController.java index 013efd1..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 @@ -REST 接口层(10 个接口) \ 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/entity/StaffAuditLog.java b/src/main/java/com/hr/staff/entity/StaffAuditLog.java index 99649ef..63668ff 100644 --- a/src/main/java/com/hr/staff/entity/StaffAuditLog.java +++ b/src/main/java/com/hr/staff/entity/StaffAuditLog.java @@ -1 +1,69 @@ -操作审计日志实体 \ No newline at end of file +package com.hr.staff.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.PrePersist; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.LocalDateTime; + +/** + * 操作审计日志表 staff_audit_log。 + */ +@Entity +@Table(name = "staff_audit_log") +@Getter +@Setter +@NoArgsConstructor +public class StaffAuditLog { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "target_type", nullable = false, length = 32) + private String targetType; + + @Column(name = "target_id", nullable = false) + private Long targetId; + + @Column(name = "operation", nullable = false, length = 32) + private String operation; + + @Column(name = "operator_id", nullable = false) + private Long operatorId; + + @Column(name = "operator_name", length = 64) + private String operatorName; + + @Column(name = "field_name", length = 64) + private String fieldName; + + @Column(name = "before_value", columnDefinition = "TEXT") + private String beforeValue; + + @Column(name = "after_value", columnDefinition = "TEXT") + private String afterValue; + + @Column(name = "request_ip", length = 64) + private String requestIp; + + @Column(name = "user_agent", length = 512) + private String userAgent; + + @Column(name = "created_at", nullable = false) + private LocalDateTime createdAt; + + @PrePersist + void onCreate() { + if (createdAt == null) { + createdAt = LocalDateTime.now(); + } + } +} diff --git a/src/main/java/com/hr/staff/entity/StaffChangeLog.java b/src/main/java/com/hr/staff/entity/StaffChangeLog.java index a7d7038..155185b 100644 --- a/src/main/java/com/hr/staff/entity/StaffChangeLog.java +++ b/src/main/java/com/hr/staff/entity/StaffChangeLog.java @@ -1 +1,60 @@ -员工变更记录实体 \ No newline at end of file +package com.hr.staff.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.PrePersist; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.LocalDateTime; + +/** + * 员工关键变更记录表 staff_change_log。 + */ +@Entity +@Table(name = "staff_change_log") +@Getter +@Setter +@NoArgsConstructor +public class StaffChangeLog { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "employee_id", nullable = false) + private Long employeeId; + + @Column(name = "change_type", nullable = false, length = 32) + private String changeType; + + @Column(name = "old_value", length = 256) + private String oldValue; + + @Column(name = "new_value", length = 256) + private String newValue; + + @Column(name = "operator_id", nullable = false) + private Long operatorId; + + @Column(name = "operator_name", length = 64) + private String operatorName; + + @Column(name = "change_reason", length = 512) + private String changeReason; + + @Column(name = "created_at", nullable = false) + private LocalDateTime createdAt; + + @PrePersist + void onCreate() { + if (createdAt == null) { + createdAt = LocalDateTime.now(); + } + } +} diff --git a/src/main/java/com/hr/staff/entity/StaffDepartmentCache.java b/src/main/java/com/hr/staff/entity/StaffDepartmentCache.java index 6d0fd0a..fc937ed 100644 --- a/src/main/java/com/hr/staff/entity/StaffDepartmentCache.java +++ b/src/main/java/com/hr/staff/entity/StaffDepartmentCache.java @@ -1 +1,58 @@ -部门缓存实体 \ No newline at end of file +package com.hr.staff.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.PrePersist; +import jakarta.persistence.PreUpdate; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.LocalDateTime; + +/** + * 部门信息本地缓存表 staff_department_cache。 + */ +@Entity +@Table(name = "staff_department_cache") +@Getter +@Setter +@NoArgsConstructor +public class StaffDepartmentCache { + + @Id + @Column(name = "id", nullable = false) + private Long id; + + @Column(name = "name", nullable = false, length = 128) + private String name; + + @Column(name = "parent_id") + private Long parentId; + + @Column(name = "level") + private Integer level; + + @Column(name = "employee_count") + private Integer employeeCount; + + @Column(name = "status", nullable = false, length = 16) + private String status = "active"; + + @Column(name = "sync_time", nullable = false) + private LocalDateTime syncTime; + + @PrePersist + void onCreate() { + if (syncTime == null) { + syncTime = LocalDateTime.now(); + } + } + + @PreUpdate + void onUpdate() { + syncTime = LocalDateTime.now(); + } +} diff --git a/src/main/java/com/hr/staff/entity/StaffEmployee.java b/src/main/java/com/hr/staff/entity/StaffEmployee.java index 1c6c780..6ac1adb 100644 --- a/src/main/java/com/hr/staff/entity/StaffEmployee.java +++ b/src/main/java/com/hr/staff/entity/StaffEmployee.java @@ -1 +1,101 @@ -员工主表实体 \ No newline at end of file +package com.hr.staff.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.PrePersist; +import jakarta.persistence.PreUpdate; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +/** + * 员工主表 staff_employee。 + */ +@Entity +@Table(name = "staff_employee") +@Getter +@Setter +@NoArgsConstructor +public class StaffEmployee { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "employee_no", nullable = false, length = 32, unique = true) + private String employeeNo; + + @Column(name = "name", nullable = false, length = 64) + private String name; + + /** 身份证号,应用层加密后存储。 */ + @Column(name = "id_number", nullable = false, length = 64, unique = true) + private String idNumber; + + @Column(name = "gender") + private Integer gender; + + @Column(name = "department_id", nullable = false) + private Long departmentId; + + @Column(name = "department_name", length = 128) + private String departmentName; + + @Column(name = "position", nullable = false, length = 128) + private String position; + + @Column(name = "hire_date", nullable = false) + private LocalDate hireDate; + + @Column(name = "phone", length = 20) + private String phone; + + @Column(name = "email", length = 128) + private String email; + + @Column(name = "emergency_contact", length = 64) + private String emergencyContact; + + @Column(name = "emergency_phone", length = 20) + private String emergencyPhone; + + @Column(name = "status", nullable = false, length = 16) + private String status = "active"; + + @Column(name = "created_by") + private Long createdBy; + + @Column(name = "updated_by") + private Long updatedBy; + + @Column(name = "created_at", nullable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + @PrePersist + void onCreate() { + if (status == null) { + status = "active"; + } + if (createdAt == null) { + createdAt = LocalDateTime.now(); + } + if (updatedAt == null) { + updatedAt = LocalDateTime.now(); + } + } + + @PreUpdate + void onUpdate() { + updatedAt = LocalDateTime.now(); + } +} diff --git a/src/main/java/com/hr/staff/entity/StaffEmployeeExtra.java b/src/main/java/com/hr/staff/entity/StaffEmployeeExtra.java index b97cd37..c563bcc 100644 --- a/src/main/java/com/hr/staff/entity/StaffEmployeeExtra.java +++ b/src/main/java/com/hr/staff/entity/StaffEmployeeExtra.java @@ -1 +1,97 @@ -员工扩展信息实体 \ No newline at end of file +package com.hr.staff.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.PrePersist; +import jakarta.persistence.PreUpdate; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +/** + * 员工扩展信息表 staff_employee_extra。 + */ +@Entity +@Table(name = "staff_employee_extra") +@Getter +@Setter +@NoArgsConstructor +public class StaffEmployeeExtra { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "employee_id", nullable = false, unique = true) + private Long employeeId; + + @Column(name = "education", length = 32) + private String education; + + @Column(name = "major", length = 128) + private String major; + + @Column(name = "school", length = 128) + private String school; + + @Column(name = "graduation_date") + private LocalDate graduationDate; + + @Column(name = "previous_company", length = 128) + private String previousCompany; + + @Column(name = "work_years") + private Integer workYears; + + @Column(name = "technical_level", length = 32) + private String technicalLevel; + + @Column(name = "salary_grade", length = 32) + private String salaryGrade; + + @Column(name = "contract_type", length = 32) + private String contractType; + + @Column(name = "contract_start") + private LocalDate contractStart; + + @Column(name = "contract_end") + private LocalDate contractEnd; + + @Column(name = "probation_end") + private LocalDate probationEnd; + + @Column(name = "address", length = 256) + private String address; + + @Column(name = "remark", columnDefinition = "TEXT") + private String remark; + + @Column(name = "created_at", nullable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + @PrePersist + void onCreate() { + if (createdAt == null) { + createdAt = LocalDateTime.now(); + } + if (updatedAt == null) { + updatedAt = LocalDateTime.now(); + } + } + + @PreUpdate + void onUpdate() { + updatedAt = LocalDateTime.now(); + } +} diff --git a/src/main/java/com/hr/staff/repository/StaffEmployeeRepository.java b/src/main/java/com/hr/staff/repository/StaffEmployeeRepository.java index 790977f..58eceab 100644 --- a/src/main/java/com/hr/staff/repository/StaffEmployeeRepository.java +++ b/src/main/java/com/hr/staff/repository/StaffEmployeeRepository.java @@ -1 +1,25 @@ -员工仓储 + Specification 动态查询 \ No newline at end of file +package com.hr.staff.repository; + +import com.hr.staff.entity.StaffEmployee; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; + +import java.util.Optional; + +public interface StaffEmployeeRepository + extends JpaRepository, JpaSpecificationExecutor { + + Optional findByEmployeeNo(String employeeNo); + + Optional findByIdNumber(String idNumber); + + boolean existsByEmployeeNo(String employeeNo); + + boolean existsByEmployeeNoAndIdNot(String employeeNo, Long id); + + boolean existsByIdNumber(String idNumber); + + boolean existsByIdNumberAndIdNot(String idNumber, Long id); + + long countByDepartmentId(Long departmentId); +} diff --git a/src/main/java/com/hr/staff/security/AesCipher.java b/src/main/java/com/hr/staff/security/AesCipher.java index 2ca4369..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 @@ -身份证 AES 加解密 \ 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/service/StaffService.java b/src/main/java/com/hr/staff/service/StaffService.java index f7ebc88..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/application-dev.yml b/src/main/resources/application-dev.yml index 802a9ad..1f4186a 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -1 +1,17 @@ -dev profile(H2) \ No newline at end of file +spring: + datasource: + url: jdbc:h2:mem:staff_mgr;MODE=MySQL;DB_CLOSE_DELAY=-1;DATABASE_TO_LOWER=TRUE + driver-class-name: org.h2.Driver + username: sa + password: + jpa: + hibernate: + ddl-auto: create-drop + open-in-view: false + properties: + hibernate: + format_sql: true + h2: + console: + enabled: true + path: /h2-console diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 7cc8fe3..b713112 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -11,8 +11,6 @@ app: jwt: secret: ${JWT_SECRET:staff-mgr-dev-secret-key-must-be-at-least-32-bytes} expiration-ms: 86400000 - crypto: - secret: ${CRYPTO_SECRET:staff-mgr-crypto-secret-32bytes-key} springdoc: swagger-ui: diff --git a/src/main/resources/db/schema.sql b/src/main/resources/db/schema.sql index 7fcb9c8..a168f90 100644 --- a/src/main/resources/db/schema.sql +++ b/src/main/resources/db/schema.sql @@ -1 +1,101 @@ -H2 建表 DDL \ 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 0e2a07b..9fbe959 100644 --- a/src/test/java/com/hr/staff/StaffApiIntegrationTest.java +++ b/src/test/java/com/hr/staff/StaffApiIntegrationTest.java @@ -1 +1,260 @@ -端到端集成测试(8 用例) \ No newline at end of file +package com.hr.staff; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.hr.staff.security.JwtService; +import org.junit.jupiter.api.Test; +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.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.assertj.core.api.Assertions.assertThat; +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; + +/** + * 员工管理模块端到端集成测试(H2 内存库 + MockMvc + JWT)。 + */ +@SpringBootTest +@AutoConfigureMockMvc +class StaffApiIntegrationTest { + + private static final AtomicInteger SEQ = new AtomicInteger(1); + private static final AtomicInteger ID_SEQ = new AtomicInteger(1); + + @Autowired + MockMvc mockMvc; + + @Autowired + ObjectMapper objectMapper; + + @Autowired + JwtService jwtService; + + private String token(String role, Long deptId) { + return jwtService.generate(9000L + SEQ.get(), role.toLowerCase() + "_user", Set.of(role), deptId); + } + + private String uniqueNo() { + return "EMP" + String.format("%08d", SEQ.incrementAndGet()); + } + + /** + * 生成符合身份证号格式且每次调用都不重复的 18 位身份证号。 + * 结构:地区码(6) + 出生日期(8) + 顺序码(3) + 校验位(1)。 + */ + private String uniqueIdNumber() { + int n = ID_SEQ.getAndIncrement() % 1000; + return String.format("41012319900101%03d4", n); + } + + private String maskedId(String idNumber) { + return idNumber.substring(0, 3) + + "*".repeat(idNumber.length() - 6) + + idNumber.substring(idNumber.length() - 3); + } + + private Map createBody(String employeeNo, String idNumber) { + Map body = new LinkedHashMap<>(); + body.put("employeeNo", employeeNo); + body.put("name", "张三"); + body.put("idNumber", idNumber); + body.put("gender", 1); + body.put("departmentId", 1001L); + body.put("position", "Java开发工程师"); + body.put("hireDate", "2024-01-10"); + body.put("phone", "13800138000"); + body.put("email", "zhangsan@example.com"); + body.put("emergencyContact", "张父"); + body.put("emergencyPhone", "13900139000"); + Map extra = new LinkedHashMap<>(); + extra.put("education", "本科"); + extra.put("technicalLevel", "P6"); + extra.put("salaryGrade", "S3"); + extra.put("address", "北京市朝阳区"); + body.put("extra", extra); + return body; + } + + private long create(String token, String employeeNo) throws Exception { + return create(token, employeeNo, uniqueIdNumber()); + } + + private long create(String token, String employeeNo, String idNumber) throws Exception { + MvcResult result = mockMvc.perform(post("/api/v1/staff") + .header("Authorization", "Bearer " + token) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(createBody(employeeNo, idNumber)))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.code").value(201)) + .andExpect(jsonPath("$.data.employeeNo").value(employeeNo)) + .andReturn(); + return objectMapper.readTree(result.getResponse().getContentAsString()) + .path("data").path("id").asLong(); + } + + @Test + void createListDetailFlow() throws Exception { + String admin = token("ADMIN", null); + String employeeNo = uniqueNo(); + String idNumber = uniqueIdNumber(); + long id = create(admin, employeeNo, idNumber); + + mockMvc.perform(get("/api/v1/staff") + .header("Authorization", "Bearer " + admin) + .param("keyword", employeeNo)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.totalElements").value(1)) + .andExpect(jsonPath("$.data.content[0].phone").value("138****8000")); + + mockMvc.perform(get("/api/v1/staff/{id}", id) + .header("Authorization", "Bearer " + admin)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.idNumber").value(maskedId(idNumber))) + .andExpect(jsonPath("$.data.extra.technicalLevel").value("P6")) + .andExpect(jsonPath("$.data.emergencyPhone").value("13900139000")); + } + + @Test + void duplicateEmployeeNoConflict() throws Exception { + String admin = token("ADMIN", null); + String employeeNo = uniqueNo(); + create(admin, employeeNo); + + mockMvc.perform(post("/api/v1/staff") + .header("Authorization", "Bearer " + admin) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(createBody(employeeNo, uniqueIdNumber())))) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value(409)); + } + + @Test + void employeeMaskingAndDataScope() throws Exception { + String admin = token("ADMIN", null); + String employeeNo = uniqueNo(); + String idNumber = uniqueIdNumber(); + long id = create(admin, employeeNo, idNumber); + + // 同部门员工:脱敏、敏感字段为 null + String sameDeptEmp = token("EMPLOYEE", 1001L); + mockMvc.perform(get("/api/v1/staff/{id}", id) + .header("Authorization", "Bearer " + sameDeptEmp)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.idNumber").value(maskedId(idNumber))) + .andExpect(jsonPath("$.data.extra.technicalLevel").isEmpty()) + .andExpect(jsonPath("$.data.emergencyPhone").value("139****9000")); + + // 跨部门员工:禁止查看 + String crossDeptEmp = token("EMPLOYEE", 1002L); + mockMvc.perform(get("/api/v1/staff/{id}", id) + .header("Authorization", "Bearer " + crossDeptEmp)) + .andExpect(status().isForbidden()); + } + + @Test + void updateAndChangeLog() throws Exception { + String admin = token("ADMIN", null); + String hr = token("HR", null); + String employeeNo = uniqueNo(); + long id = create(admin, employeeNo); + + Map update = new LinkedHashMap<>(); + update.put("position", "高级Java开发工程师"); + mockMvc.perform(put("/api/v1/staff/{id}", id) + .header("Authorization", "Bearer " + hr) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(update))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.position").value("高级Java开发工程师")); + + mockMvc.perform(get("/api/v1/staff/{id}/change-logs", id) + .header("Authorization", "Bearer " + admin)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.content[0].changeType").value("POSITION_CHANGE")) + .andExpect(jsonPath("$.data.content[0].newValue").value("高级Java开发工程师")); + } + + @Test + void checkEmployeeNo() throws Exception { + String admin = token("ADMIN", null); + String employeeNo = uniqueNo(); + create(admin, employeeNo); + + mockMvc.perform(get("/api/v1/staff/check/employee-no") + .header("Authorization", "Bearer " + admin) + .param("employeeNo", employeeNo)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.exists").value(true)); + + mockMvc.perform(get("/api/v1/staff/check/employee-no") + .header("Authorization", "Bearer " + admin) + .param("employeeNo", "NOT_EXIST")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.exists").value(false)); + } + + @Test + void deleteFlow() throws Exception { + String admin = token("ADMIN", null); + String employeeNo = uniqueNo(); + long id = create(admin, employeeNo); + + mockMvc.perform(delete("/api/v1/staff/{id}", id) + .header("Authorization", "Bearer " + admin)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("inactive")); + + // 重复删除应失败 + mockMvc.perform(delete("/api/v1/staff/{id}", id) + .header("Authorization", "Bearer " + admin)) + .andExpect(status().isBadRequest()); + } + + @Test + void batchDeleteFlow() throws Exception { + String admin = token("ADMIN", null); + long id1 = create(admin, uniqueNo()); + long id2 = create(admin, uniqueNo()); + + Map body = new LinkedHashMap<>(); + body.put("ids", new long[]{id1, id2}); + MvcResult result = mockMvc.perform(post("/api/v1/staff/batch-delete") + .header("Authorization", "Bearer " + admin) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(body))) + .andExpect(status().isOk()) + .andReturn(); + JsonNode data = objectMapper.readTree(result.getResponse().getContentAsString()).path("data"); + assertThat(data.path("successCount").asInt()).isEqualTo(2); + assertThat(data.path("failedList").size()).isEqualTo(0); + } + + @Test + void auditLogsAndDepartments() throws Exception { + String admin = token("ADMIN", null); + create(admin, uniqueNo()); + + mockMvc.perform(get("/api/v1/staff/audit-logs") + .header("Authorization", "Bearer " + admin)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.totalElements").isNumber()); + + mockMvc.perform(get("/api/v1/staff/departments") + .header("Authorization", "Bearer " + admin)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.length()").value(3)); + } +}