develop: 实现员工管理模块 staff 增删改查 REST 接口
This commit is contained in:
parent
e541c50f41
commit
6e03322ef0
48
README.md
48
README.md
@ -1,2 +1,48 @@
|
||||
# staff_mgr
|
||||
# staff-mgr 员工管理模块
|
||||
|
||||
员工管理模块,提供员工(staff)增删改查 REST 接口。
|
||||
|
||||
## 技术栈
|
||||
- Java 17 / Spring Boot 3.2
|
||||
- Spring Data JPA
|
||||
- MySQL 8.0(生产)/ H2(本地开发 & 测试)
|
||||
- Spring Security + JWT(对接 auth-mgr 的 Bearer Token)
|
||||
- SpringDoc OpenAPI
|
||||
|
||||
## 快速开始
|
||||
```bash
|
||||
# 本地开发(H2 内存库,无需外部依赖)
|
||||
mvn spring-boot:run
|
||||
|
||||
# 生产(MySQL)
|
||||
mvn spring-boot:run -Dspring-boot.run.profiles=mysql
|
||||
```
|
||||
|
||||
启动后:
|
||||
- API 文档: http://localhost:8080/swagger-ui.html
|
||||
- H2 控制台(仅 dev): http://localhost:8080/h2-console
|
||||
|
||||
## 接口一览
|
||||
基础路径 `/api/v1/staff`,所有接口需携带 `Authorization: Bearer <JWT>`。
|
||||
|
||||
| 方法 | 路径 | 说明 | 权限 |
|
||||
|------|------|------|------|
|
||||
| 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`。
|
||||
|
||||
118
pom.xml
Normal file
118
pom.xml
Normal file
@ -0,0 +1,118 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-parent</artifactId>
|
||||
<version>3.2.5</version>
|
||||
<relativePath/>
|
||||
</parent>
|
||||
|
||||
<groupId>com.hr</groupId>
|
||||
<artifactId>staff-mgr</artifactId>
|
||||
<version>0.1.0-SNAPSHOT</version>
|
||||
<name>staff-mgr</name>
|
||||
<description>员工管理模块 - staff CRUD REST API</description>
|
||||
|
||||
<properties>
|
||||
<java.version>17</java.version>
|
||||
<jjwt.version>0.12.6</jjwt.version>
|
||||
<springdoc.version>2.5.0</springdoc.version>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-web</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-data-jpa</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-validation</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-security</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- JWT -->
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-api</artifactId>
|
||||
<version>${jjwt.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-impl</artifactId>
|
||||
<version>${jjwt.version}</version>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-jackson</artifactId>
|
||||
<version>${jjwt.version}</version>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- API 文档 -->
|
||||
<dependency>
|
||||
<groupId>org.springdoc</groupId>
|
||||
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
|
||||
<version>${springdoc.version}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- 数据库驱动 -->
|
||||
<dependency>
|
||||
<groupId>com.mysql</groupId>
|
||||
<artifactId>mysql-connector-j</artifactId>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.h2database</groupId>
|
||||
<artifactId>h2</artifactId>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- Lombok -->
|
||||
<dependency>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
|
||||
<!-- 测试 -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.security</groupId>
|
||||
<artifactId>spring-security-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
<configuration>
|
||||
<excludes>
|
||||
<exclude>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
</exclude>
|
||||
</excludes>
|
||||
</configuration>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
15
src/main/java/com/hr/staff/StaffMgrApplication.java
Normal file
15
src/main/java/com/hr/staff/StaffMgrApplication.java
Normal file
@ -0,0 +1,15 @@
|
||||
package com.hr.staff;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
|
||||
/**
|
||||
* 员工管理模块(staff-mgr)启动入口。
|
||||
*/
|
||||
@SpringBootApplication
|
||||
public class StaffMgrApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(StaffMgrApplication.class, args);
|
||||
}
|
||||
}
|
||||
27
src/main/java/com/hr/staff/common/ApiResponse.java
Normal file
27
src/main/java/com/hr/staff/common/ApiResponse.java
Normal file
@ -0,0 +1,27 @@
|
||||
package com.hr.staff.common;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* 统一 API 响应结构。
|
||||
*/
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class ApiResponse<T> {
|
||||
|
||||
private int code;
|
||||
private String message;
|
||||
private T data;
|
||||
private long timestamp = System.currentTimeMillis();
|
||||
|
||||
public static <T> ApiResponse<T> ok(T data) {
|
||||
return new ApiResponse<>(200, "success", data, System.currentTimeMillis());
|
||||
}
|
||||
|
||||
public static <T> ApiResponse<T> of(int code, String message, T data) {
|
||||
return new ApiResponse<>(code, message, data, System.currentTimeMillis());
|
||||
}
|
||||
}
|
||||
38
src/main/java/com/hr/staff/common/BusinessException.java
Normal file
38
src/main/java/com/hr/staff/common/BusinessException.java
Normal file
@ -0,0 +1,38 @@
|
||||
package com.hr.staff.common;
|
||||
|
||||
/**
|
||||
* 业务异常,携带 HTTP 状态码对应的错误码。
|
||||
*/
|
||||
public class BusinessException extends RuntimeException {
|
||||
|
||||
private final int code;
|
||||
|
||||
public BusinessException(int code, String message) {
|
||||
super(message);
|
||||
this.code = code;
|
||||
}
|
||||
|
||||
public int getCode() {
|
||||
return code;
|
||||
}
|
||||
|
||||
public static BusinessException badRequest(String message) {
|
||||
return new BusinessException(400, message);
|
||||
}
|
||||
|
||||
public static BusinessException unauthorized(String message) {
|
||||
return new BusinessException(401, message);
|
||||
}
|
||||
|
||||
public static BusinessException forbidden(String message) {
|
||||
return new BusinessException(403, message);
|
||||
}
|
||||
|
||||
public static BusinessException notFound(String message) {
|
||||
return new BusinessException(404, message);
|
||||
}
|
||||
|
||||
public static BusinessException conflict(String message) {
|
||||
return new BusinessException(409, message);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,68 @@
|
||||
package com.hr.staff.common;
|
||||
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.http.converter.HttpMessageNotReadableException;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
import org.springframework.validation.FieldError;
|
||||
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
|
||||
/**
|
||||
* 全局异常处理,将异常转换为统一响应结构。
|
||||
*/
|
||||
@RestControllerAdvice
|
||||
public class GlobalExceptionHandler {
|
||||
|
||||
@ExceptionHandler(BusinessException.class)
|
||||
public ResponseEntity<ApiResponse<Void>> handleBusiness(BusinessException e) {
|
||||
HttpStatus status = resolveStatus(e.getCode());
|
||||
return ResponseEntity.status(status)
|
||||
.body(ApiResponse.of(e.getCode(), e.getMessage(), null));
|
||||
}
|
||||
|
||||
@ExceptionHandler(MethodArgumentNotValidException.class)
|
||||
public ResponseEntity<ApiResponse<Void>> handleValidation(MethodArgumentNotValidException e) {
|
||||
FieldError fieldError = e.getBindingResult().getFieldError();
|
||||
String message = fieldError != null
|
||||
? fieldError.getField() + ": " + fieldError.getDefaultMessage()
|
||||
: "请求参数校验失败";
|
||||
return ResponseEntity.badRequest().body(ApiResponse.of(400, message, null));
|
||||
}
|
||||
|
||||
@ExceptionHandler(HttpMessageNotReadableException.class)
|
||||
public ResponseEntity<ApiResponse<Void>> handleUnreadable(HttpMessageNotReadableException e) {
|
||||
return ResponseEntity.badRequest().body(ApiResponse.of(400, "请求参数格式错误", null));
|
||||
}
|
||||
|
||||
@ExceptionHandler(DataIntegrityViolationException.class)
|
||||
public ResponseEntity<ApiResponse<Void>> handleConflict(DataIntegrityViolationException e) {
|
||||
return ResponseEntity.status(HttpStatus.CONFLICT)
|
||||
.body(ApiResponse.of(409, "数据冲突,请检查工号或身份证号是否重复", null));
|
||||
}
|
||||
|
||||
@ExceptionHandler(AccessDeniedException.class)
|
||||
public ResponseEntity<ApiResponse<Void>> handleAccessDenied(AccessDeniedException e) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||
.body(ApiResponse.of(403, "无权限访问该资源", null));
|
||||
}
|
||||
|
||||
@ExceptionHandler(Exception.class)
|
||||
public ResponseEntity<ApiResponse<Void>> handleOther(Exception e) {
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(ApiResponse.of(500, "服务器内部错误: " + e.getMessage(), null));
|
||||
}
|
||||
|
||||
private HttpStatus resolveStatus(int code) {
|
||||
return switch (code) {
|
||||
case 400 -> HttpStatus.BAD_REQUEST;
|
||||
case 401 -> HttpStatus.UNAUTHORIZED;
|
||||
case 403 -> HttpStatus.FORBIDDEN;
|
||||
case 404 -> HttpStatus.NOT_FOUND;
|
||||
case 409 -> HttpStatus.CONFLICT;
|
||||
default -> HttpStatus.INTERNAL_SERVER_ERROR;
|
||||
};
|
||||
}
|
||||
}
|
||||
38
src/main/java/com/hr/staff/common/MaskUtil.java
Normal file
38
src/main/java/com/hr/staff/common/MaskUtil.java
Normal file
@ -0,0 +1,38 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
37
src/main/java/com/hr/staff/common/PageResult.java
Normal file
37
src/main/java/com/hr/staff/common/PageResult.java
Normal file
@ -0,0 +1,37 @@
|
||||
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<T> {
|
||||
|
||||
private List<T> content;
|
||||
private long totalElements;
|
||||
private int totalPages;
|
||||
private int number;
|
||||
private int size;
|
||||
private boolean first;
|
||||
private boolean last;
|
||||
|
||||
public static <T> PageResult<T> of(Page<?> page, List<T> content) {
|
||||
return new PageResult<>(
|
||||
content,
|
||||
page.getTotalElements(),
|
||||
page.getTotalPages(),
|
||||
page.getNumber(),
|
||||
page.getSize(),
|
||||
page.isFirst(),
|
||||
page.isLast()
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,51 @@
|
||||
package com.hr.staff.config;
|
||||
|
||||
import com.hr.staff.security.AuthUser;
|
||||
import com.hr.staff.security.JwtService;
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 解析 Authorization: Bearer <JWT> 并注入 Spring Security 上下文。
|
||||
*/
|
||||
public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||
|
||||
private final JwtService jwtService;
|
||||
|
||||
public JwtAuthenticationFilter(JwtService jwtService) {
|
||||
this.jwtService = jwtService;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
FilterChain filterChain) throws ServletException, IOException {
|
||||
String header = request.getHeader("Authorization");
|
||||
if (header != null && header.startsWith("Bearer ")) {
|
||||
String token = header.substring(7);
|
||||
try {
|
||||
AuthUser user = jwtService.parse(token);
|
||||
List<SimpleGrantedAuthority> authorities = user.roles().stream()
|
||||
.map(r -> new SimpleGrantedAuthority("ROLE_" + r))
|
||||
.toList();
|
||||
UsernamePasswordAuthenticationToken authentication =
|
||||
new UsernamePasswordAuthenticationToken(user, null, authorities);
|
||||
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
|
||||
SecurityContextHolder.getContext().setAuthentication(authentication);
|
||||
} catch (Exception e) {
|
||||
SecurityContextHolder.clearContext();
|
||||
}
|
||||
}
|
||||
filterChain.doFilter(request, response);
|
||||
}
|
||||
}
|
||||
49
src/main/java/com/hr/staff/config/SecurityConfig.java
Normal file
49
src/main/java/com/hr/staff/config/SecurityConfig.java
Normal file
@ -0,0 +1,49 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
133
src/main/java/com/hr/staff/controller/StaffController.java
Normal file
133
src/main/java/com/hr/staff/controller/StaffController.java
Normal file
@ -0,0 +1,133 @@
|
||||
package com.hr.staff.controller;
|
||||
|
||||
import com.hr.staff.common.ApiResponse;
|
||||
import com.hr.staff.common.PageResult;
|
||||
import com.hr.staff.dto.AuditLogItem;
|
||||
import com.hr.staff.dto.BatchDeleteRequest;
|
||||
import com.hr.staff.dto.BatchDeleteResult;
|
||||
import com.hr.staff.dto.ChangeLogItem;
|
||||
import com.hr.staff.dto.CheckEmployeeNoResult;
|
||||
import com.hr.staff.dto.DepartmentItem;
|
||||
import com.hr.staff.dto.StaffBrief;
|
||||
import com.hr.staff.dto.StaffCreateRequest;
|
||||
import com.hr.staff.dto.StaffDetailResponse;
|
||||
import com.hr.staff.dto.StaffListItem;
|
||||
import com.hr.staff.dto.StaffUpdateRequest;
|
||||
import com.hr.staff.security.SecurityUtils;
|
||||
import com.hr.staff.service.StaffService;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.format.annotation.DateTimeFormat;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 员工管理 REST 接口,基础路径 /api/v1/staff。
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/staff")
|
||||
public class StaffController {
|
||||
|
||||
private final StaffService staffService;
|
||||
|
||||
public StaffController(StaffService staffService) {
|
||||
this.staffService = staffService;
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
@PreAuthorize("hasAnyRole('ADMIN','HR')")
|
||||
public ResponseEntity<ApiResponse<StaffBrief>> create(@Valid @RequestBody StaffCreateRequest request) {
|
||||
StaffBrief brief = staffService.create(request, SecurityUtils.currentUser());
|
||||
return ResponseEntity.status(HttpStatus.CREATED)
|
||||
.body(ApiResponse.of(201, "员工创建成功", brief));
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public ApiResponse<PageResult<StaffListItem>> list(
|
||||
@RequestParam(required = false) String keyword,
|
||||
@RequestParam(required = false) Long departmentId,
|
||||
@RequestParam(required = false) String position,
|
||||
@RequestParam(required = false) String status,
|
||||
@RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate hireDateStart,
|
||||
@RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate hireDateEnd,
|
||||
@RequestParam(defaultValue = "1") int page,
|
||||
@RequestParam(defaultValue = "20") int size,
|
||||
@RequestParam(required = false) String sort) {
|
||||
return ApiResponse.ok(staffService.list(keyword, departmentId, position, status,
|
||||
hireDateStart, hireDateEnd, page, size, sort, SecurityUtils.currentUser()));
|
||||
}
|
||||
|
||||
@GetMapping("/{id}")
|
||||
public ApiResponse<StaffDetailResponse> detail(@PathVariable Long id) {
|
||||
return ApiResponse.ok(staffService.detail(id, SecurityUtils.currentUser()));
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
public ApiResponse<StaffDetailResponse> update(@PathVariable Long id,
|
||||
@Valid @RequestBody StaffUpdateRequest request) {
|
||||
return ApiResponse.ok(staffService.update(id, request, SecurityUtils.currentUser()));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
@PreAuthorize("hasAnyRole('ADMIN','HR')")
|
||||
public ApiResponse<Map<String, Object>> delete(@PathVariable Long id) {
|
||||
return ApiResponse.ok(staffService.delete(id, SecurityUtils.currentUser()));
|
||||
}
|
||||
|
||||
@PostMapping("/batch-delete")
|
||||
@PreAuthorize("hasAnyRole('ADMIN','HR')")
|
||||
public ApiResponse<BatchDeleteResult> batchDelete(@Valid @RequestBody BatchDeleteRequest request) {
|
||||
return ApiResponse.ok(staffService.batchDelete(request.getIds(), SecurityUtils.currentUser()));
|
||||
}
|
||||
|
||||
@GetMapping("/{id}/change-logs")
|
||||
@PreAuthorize("hasAnyRole('ADMIN','HR')")
|
||||
public ApiResponse<PageResult<ChangeLogItem>> changeLogs(
|
||||
@PathVariable Long id,
|
||||
@RequestParam(required = false) String changeType,
|
||||
@RequestParam(defaultValue = "1") int page,
|
||||
@RequestParam(defaultValue = "20") int size) {
|
||||
return ApiResponse.ok(staffService.changeLogs(id, changeType, page, size));
|
||||
}
|
||||
|
||||
@GetMapping("/audit-logs")
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
public ApiResponse<PageResult<AuditLogItem>> auditLogs(
|
||||
@RequestParam(required = false) String targetType,
|
||||
@RequestParam(required = false) Long targetId,
|
||||
@RequestParam(required = false) String operation,
|
||||
@RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime startTime,
|
||||
@RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime endTime,
|
||||
@RequestParam(defaultValue = "1") int page,
|
||||
@RequestParam(defaultValue = "20") int size) {
|
||||
return ApiResponse.ok(staffService.auditLogs(targetType, targetId, operation,
|
||||
startTime, endTime, page, size));
|
||||
}
|
||||
|
||||
@GetMapping("/check/employee-no")
|
||||
@PreAuthorize("hasAnyRole('ADMIN','HR')")
|
||||
public ApiResponse<CheckEmployeeNoResult> checkEmployeeNo(
|
||||
@RequestParam String employeeNo,
|
||||
@RequestParam(required = false) Long excludeId) {
|
||||
return ApiResponse.ok(staffService.checkEmployeeNo(employeeNo, excludeId));
|
||||
}
|
||||
|
||||
@GetMapping("/departments")
|
||||
public ApiResponse<List<DepartmentItem>> departments() {
|
||||
return ApiResponse.ok(staffService.departments());
|
||||
}
|
||||
}
|
||||
33
src/main/java/com/hr/staff/dto/AuditLogItem.java
Normal file
33
src/main/java/com/hr/staff/dto/AuditLogItem.java
Normal file
@ -0,0 +1,33 @@
|
||||
package com.hr.staff.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 操作审计日志条目。
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class AuditLogItem {
|
||||
|
||||
private Long id;
|
||||
private String targetType;
|
||||
private Long targetId;
|
||||
private String operation;
|
||||
private Long operatorId;
|
||||
private String operatorName;
|
||||
private String fieldName;
|
||||
private String beforeValue;
|
||||
private String afterValue;
|
||||
private String requestIp;
|
||||
|
||||
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
16
src/main/java/com/hr/staff/dto/BatchDeleteRequest.java
Normal file
16
src/main/java/com/hr/staff/dto/BatchDeleteRequest.java
Normal file
@ -0,0 +1,16 @@
|
||||
package com.hr.staff.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 批量删除请求体。
|
||||
*/
|
||||
@Data
|
||||
public class BatchDeleteRequest {
|
||||
|
||||
@NotEmpty(message = "ids 不能为空")
|
||||
private List<Long> ids;
|
||||
}
|
||||
30
src/main/java/com/hr/staff/dto/BatchDeleteResult.java
Normal file
30
src/main/java/com/hr/staff/dto/BatchDeleteResult.java
Normal file
@ -0,0 +1,30 @@
|
||||
package com.hr.staff.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 批量删除结果。
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class BatchDeleteResult {
|
||||
|
||||
private int successCount;
|
||||
private List<FailedItem> failedList;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public static class FailedItem {
|
||||
private Long id;
|
||||
private String reason;
|
||||
}
|
||||
}
|
||||
30
src/main/java/com/hr/staff/dto/ChangeLogItem.java
Normal file
30
src/main/java/com/hr/staff/dto/ChangeLogItem.java
Normal file
@ -0,0 +1,30 @@
|
||||
package com.hr.staff.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 员工变更记录条目。
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class ChangeLogItem {
|
||||
|
||||
private Long id;
|
||||
private String changeType;
|
||||
private String oldValue;
|
||||
private String newValue;
|
||||
private Long operatorId;
|
||||
private String operatorName;
|
||||
private String changeReason;
|
||||
|
||||
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
18
src/main/java/com/hr/staff/dto/CheckEmployeeNoResult.java
Normal file
18
src/main/java/com/hr/staff/dto/CheckEmployeeNoResult.java
Normal file
@ -0,0 +1,18 @@
|
||||
package com.hr.staff.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* 工号查重结果。
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class CheckEmployeeNoResult {
|
||||
|
||||
private boolean exists;
|
||||
}
|
||||
22
src/main/java/com/hr/staff/dto/DepartmentItem.java
Normal file
22
src/main/java/com/hr/staff/dto/DepartmentItem.java
Normal file
@ -0,0 +1,22 @@
|
||||
package com.hr.staff.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* 部门缓存条目。
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class DepartmentItem {
|
||||
|
||||
private Long id;
|
||||
private String name;
|
||||
private Long parentId;
|
||||
private Integer level;
|
||||
private Integer employeeCount;
|
||||
}
|
||||
44
src/main/java/com/hr/staff/dto/ExtraDto.java
Normal file
44
src/main/java/com/hr/staff/dto/ExtraDto.java
Normal file
@ -0,0 +1,44 @@
|
||||
package com.hr.staff.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
/**
|
||||
* 员工扩展信息 DTO(创建/更新/详情共用)。
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class ExtraDto {
|
||||
|
||||
private String education;
|
||||
private String major;
|
||||
private String school;
|
||||
|
||||
@JsonFormat(pattern = "yyyy-MM-dd")
|
||||
private LocalDate graduationDate;
|
||||
|
||||
private String previousCompany;
|
||||
private Integer workYears;
|
||||
private String technicalLevel;
|
||||
private String salaryGrade;
|
||||
private String contractType;
|
||||
|
||||
@JsonFormat(pattern = "yyyy-MM-dd")
|
||||
private LocalDate contractStart;
|
||||
|
||||
@JsonFormat(pattern = "yyyy-MM-dd")
|
||||
private LocalDate contractEnd;
|
||||
|
||||
@JsonFormat(pattern = "yyyy-MM-dd")
|
||||
private LocalDate probationEnd;
|
||||
|
||||
private String address;
|
||||
private String remark;
|
||||
}
|
||||
20
src/main/java/com/hr/staff/dto/StaffBrief.java
Normal file
20
src/main/java/com/hr/staff/dto/StaffBrief.java
Normal file
@ -0,0 +1,20 @@
|
||||
package com.hr.staff.dto;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* 创建员工后的简要返回信息。
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class StaffBrief {
|
||||
|
||||
private Long id;
|
||||
private String employeeNo;
|
||||
private String name;
|
||||
}
|
||||
60
src/main/java/com/hr/staff/dto/StaffCreateRequest.java
Normal file
60
src/main/java/com/hr/staff/dto/StaffCreateRequest.java
Normal file
@ -0,0 +1,60 @@
|
||||
package com.hr.staff.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import jakarta.validation.constraints.Email;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
/**
|
||||
* 创建员工请求体。
|
||||
*/
|
||||
@Data
|
||||
public class StaffCreateRequest {
|
||||
|
||||
@NotBlank(message = "工号不能为空")
|
||||
@Size(max = 32, message = "工号最长32位")
|
||||
@Pattern(regexp = "^[A-Za-z0-9]+$", message = "工号仅支持字母数字组合")
|
||||
private String employeeNo;
|
||||
|
||||
@NotBlank(message = "姓名不能为空")
|
||||
@Size(min = 2, max = 64, message = "姓名长度需在2-64位之间")
|
||||
private String name;
|
||||
|
||||
@NotBlank(message = "身份证号不能为空")
|
||||
@Size(min = 18, max = 18, message = "身份证号必须为18位")
|
||||
private String idNumber;
|
||||
|
||||
private Integer gender;
|
||||
|
||||
@NotNull(message = "部门ID不能为空")
|
||||
private Long departmentId;
|
||||
|
||||
@NotBlank(message = "岗位不能为空")
|
||||
@Size(max = 128, message = "岗位最长128位")
|
||||
private String position;
|
||||
|
||||
@NotNull(message = "入职日期不能为空")
|
||||
@JsonFormat(pattern = "yyyy-MM-dd")
|
||||
private LocalDate hireDate;
|
||||
|
||||
@Pattern(regexp = "^1\\d{10}$", message = "手机号格式不正确")
|
||||
private String phone;
|
||||
|
||||
@Email(message = "邮箱格式不正确")
|
||||
private String email;
|
||||
|
||||
@Size(max = 64, message = "紧急联系人最长64位")
|
||||
private String emergencyContact;
|
||||
|
||||
@Size(max = 20, message = "紧急联系人电话最长20位")
|
||||
private String emergencyPhone;
|
||||
|
||||
private String status;
|
||||
|
||||
private ExtraDto extra;
|
||||
}
|
||||
45
src/main/java/com/hr/staff/dto/StaffDetailResponse.java
Normal file
45
src/main/java/com/hr/staff/dto/StaffDetailResponse.java
Normal file
@ -0,0 +1,45 @@
|
||||
package com.hr.staff.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 员工详情响应。
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class StaffDetailResponse {
|
||||
|
||||
private Long id;
|
||||
private String employeeNo;
|
||||
private String name;
|
||||
private String idNumber;
|
||||
private Integer gender;
|
||||
private Long departmentId;
|
||||
private String departmentName;
|
||||
private String position;
|
||||
|
||||
@JsonFormat(pattern = "yyyy-MM-dd")
|
||||
private LocalDate hireDate;
|
||||
|
||||
private String phone;
|
||||
private String email;
|
||||
private String emergencyContact;
|
||||
private String emergencyPhone;
|
||||
private String status;
|
||||
private ExtraDto extra;
|
||||
|
||||
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
38
src/main/java/com/hr/staff/dto/StaffListItem.java
Normal file
38
src/main/java/com/hr/staff/dto/StaffListItem.java
Normal file
@ -0,0 +1,38 @@
|
||||
package com.hr.staff.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 员工列表项(不含身份证号、技术级别、薪资等级等敏感字段)。
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class StaffListItem {
|
||||
|
||||
private Long id;
|
||||
private String employeeNo;
|
||||
private String name;
|
||||
private Integer gender;
|
||||
private Long departmentId;
|
||||
private String departmentName;
|
||||
private String position;
|
||||
|
||||
@JsonFormat(pattern = "yyyy-MM-dd")
|
||||
private LocalDate hireDate;
|
||||
|
||||
private String phone;
|
||||
private String email;
|
||||
private String status;
|
||||
|
||||
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
42
src/main/java/com/hr/staff/dto/StaffUpdateRequest.java
Normal file
42
src/main/java/com/hr/staff/dto/StaffUpdateRequest.java
Normal file
@ -0,0 +1,42 @@
|
||||
package com.hr.staff.dto;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import jakarta.validation.constraints.Email;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
/**
|
||||
* 更新员工请求体(部分更新,仅传需要修改的字段)。
|
||||
*/
|
||||
@Data
|
||||
public class StaffUpdateRequest {
|
||||
|
||||
private Long departmentId;
|
||||
|
||||
@Size(max = 128, message = "岗位最长128位")
|
||||
private String position;
|
||||
|
||||
@JsonFormat(pattern = "yyyy-MM-dd")
|
||||
private LocalDate hireDate;
|
||||
|
||||
@Pattern(regexp = "^1\\d{10}$", message = "手机号格式不正确")
|
||||
private String phone;
|
||||
|
||||
@Email(message = "邮箱格式不正确")
|
||||
private String email;
|
||||
|
||||
@Size(max = 64, message = "紧急联系人最长64位")
|
||||
private String emergencyContact;
|
||||
|
||||
@Size(max = 20, message = "紧急联系人电话最长20位")
|
||||
private String emergencyPhone;
|
||||
|
||||
private Integer gender;
|
||||
|
||||
private String status;
|
||||
|
||||
private ExtraDto extra;
|
||||
}
|
||||
69
src/main/java/com/hr/staff/entity/StaffAuditLog.java
Normal file
69
src/main/java/com/hr/staff/entity/StaffAuditLog.java
Normal file
@ -0,0 +1,69 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
60
src/main/java/com/hr/staff/entity/StaffChangeLog.java
Normal file
60
src/main/java/com/hr/staff/entity/StaffChangeLog.java
Normal file
@ -0,0 +1,60 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
58
src/main/java/com/hr/staff/entity/StaffDepartmentCache.java
Normal file
58
src/main/java/com/hr/staff/entity/StaffDepartmentCache.java
Normal file
@ -0,0 +1,58 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
101
src/main/java/com/hr/staff/entity/StaffEmployee.java
Normal file
101
src/main/java/com/hr/staff/entity/StaffEmployee.java
Normal file
@ -0,0 +1,101 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
97
src/main/java/com/hr/staff/entity/StaffEmployeeExtra.java
Normal file
97
src/main/java/com/hr/staff/entity/StaffEmployeeExtra.java
Normal file
@ -0,0 +1,97 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
package com.hr.staff.repository;
|
||||
|
||||
import com.hr.staff.entity.StaffAuditLog;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
||||
|
||||
public interface StaffAuditLogRepository
|
||||
extends JpaRepository<StaffAuditLog, Long>, JpaSpecificationExecutor<StaffAuditLog> {
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
package com.hr.staff.repository;
|
||||
|
||||
import com.hr.staff.entity.StaffChangeLog;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
||||
|
||||
public interface StaffChangeLogRepository
|
||||
extends JpaRepository<StaffChangeLog, Long>, JpaSpecificationExecutor<StaffChangeLog> {
|
||||
}
|
||||
@ -0,0 +1,11 @@
|
||||
package com.hr.staff.repository;
|
||||
|
||||
import com.hr.staff.entity.StaffDepartmentCache;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface StaffDepartmentCacheRepository extends JpaRepository<StaffDepartmentCache, Long> {
|
||||
|
||||
List<StaffDepartmentCache> findAllByOrderByIdAsc();
|
||||
}
|
||||
@ -0,0 +1,15 @@
|
||||
package com.hr.staff.repository;
|
||||
|
||||
import com.hr.staff.entity.StaffEmployeeExtra;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface StaffEmployeeExtraRepository extends JpaRepository<StaffEmployeeExtra, Long> {
|
||||
|
||||
Optional<StaffEmployeeExtra> findByEmployeeId(Long employeeId);
|
||||
|
||||
List<StaffEmployeeExtra> findByEmployeeIdIn(Collection<Long> employeeIds);
|
||||
}
|
||||
@ -0,0 +1,25 @@
|
||||
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<StaffEmployee, Long>, JpaSpecificationExecutor<StaffEmployee> {
|
||||
|
||||
Optional<StaffEmployee> findByEmployeeNo(String employeeNo);
|
||||
|
||||
Optional<StaffEmployee> 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);
|
||||
}
|
||||
60
src/main/java/com/hr/staff/security/AesCipher.java
Normal file
60
src/main/java/com/hr/staff/security/AesCipher.java
Normal file
@ -0,0 +1,60 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
29
src/main/java/com/hr/staff/security/AuthUser.java
Normal file
29
src/main/java/com/hr/staff/security/AuthUser.java
Normal file
@ -0,0 +1,29 @@
|
||||
package com.hr.staff.security;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 从 JWT 解析出的当前登录用户信息。
|
||||
*/
|
||||
public record AuthUser(Long id, String username, Set<String> roles, Long departmentId) {
|
||||
|
||||
public boolean hasRole(String role) {
|
||||
return roles != null && roles.contains(role);
|
||||
}
|
||||
|
||||
public boolean isAdmin() {
|
||||
return hasRole("ADMIN");
|
||||
}
|
||||
|
||||
public boolean isHr() {
|
||||
return hasRole("HR");
|
||||
}
|
||||
|
||||
public boolean isEmployee() {
|
||||
return hasRole("EMPLOYEE");
|
||||
}
|
||||
|
||||
public boolean isAdminOrHr() {
|
||||
return isAdmin() || isHr();
|
||||
}
|
||||
}
|
||||
61
src/main/java/com/hr/staff/security/JwtService.java
Normal file
61
src/main/java/com/hr/staff/security/JwtService.java
Normal file
@ -0,0 +1,61 @@
|
||||
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;
|
||||
|
||||
/**
|
||||
* JWT 生成与解析。本模块负责解析 auth-mgr 签发的 Bearer Token;
|
||||
* 同时提供生成能力,便于本地开发与集成测试构造不同角色的 Token。
|
||||
*/
|
||||
@Component
|
||||
public class JwtService {
|
||||
|
||||
private final SecretKey key;
|
||||
private final long expirationMs;
|
||||
|
||||
public JwtService(@Value("${app.jwt.secret}") String secret,
|
||||
@Value("${app.jwt.expiration-ms:86400000}") long expirationMs) {
|
||||
this.key = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
|
||||
this.expirationMs = expirationMs;
|
||||
}
|
||||
|
||||
public String generate(Long id, String username, Set<String> roles, Long departmentId) {
|
||||
JwtBuilder builder = Jwts.builder()
|
||||
.subject(String.valueOf(id))
|
||||
.claim("username", username)
|
||||
.claim("roles", roles)
|
||||
.issuedAt(new Date())
|
||||
.expiration(new Date(System.currentTimeMillis() + expirationMs));
|
||||
if (departmentId != null) {
|
||||
builder.claim("deptId", departmentId);
|
||||
}
|
||||
return builder.signWith(key).compact();
|
||||
}
|
||||
|
||||
public AuthUser parse(String token) {
|
||||
Claims claims = Jwts.parser()
|
||||
.verifyWith(key)
|
||||
.build()
|
||||
.parseSignedClaims(token)
|
||||
.getPayload();
|
||||
Long id = Long.valueOf(claims.getSubject());
|
||||
String username = claims.get("username", String.class);
|
||||
List<?> rawRoles = claims.get("roles", List.class);
|
||||
Set<String> roles = rawRoles == null
|
||||
? Set.of()
|
||||
: rawRoles.stream().map(String::valueOf).collect(Collectors.toSet());
|
||||
Number deptId = claims.get("deptId", Number.class);
|
||||
return new AuthUser(id, username, roles, deptId == null ? null : deptId.longValue());
|
||||
}
|
||||
}
|
||||
22
src/main/java/com/hr/staff/security/SecurityUtils.java
Normal file
22
src/main/java/com/hr/staff/security/SecurityUtils.java
Normal file
@ -0,0 +1,22 @@
|
||||
package com.hr.staff.security;
|
||||
|
||||
import com.hr.staff.common.BusinessException;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
|
||||
/**
|
||||
* 从安全上下文获取当前登录用户。
|
||||
*/
|
||||
public final class SecurityUtils {
|
||||
|
||||
private SecurityUtils() {
|
||||
}
|
||||
|
||||
public static AuthUser currentUser() {
|
||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (auth != null && auth.getPrincipal() instanceof AuthUser user) {
|
||||
return user;
|
||||
}
|
||||
throw BusinessException.unauthorized("未认证或 Token 无效");
|
||||
}
|
||||
}
|
||||
42
src/main/java/com/hr/staff/service/DataInitializer.java
Normal file
42
src/main/java/com/hr/staff/service/DataInitializer.java
Normal file
@ -0,0 +1,42 @@
|
||||
package com.hr.staff.service;
|
||||
|
||||
import com.hr.staff.entity.StaffDepartmentCache;
|
||||
import com.hr.staff.repository.StaffDepartmentCacheRepository;
|
||||
import org.springframework.boot.CommandLineRunner;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* 开发/测试环境初始化部门缓存数据,便于端到端验证。
|
||||
*/
|
||||
@Component
|
||||
public class DataInitializer implements CommandLineRunner {
|
||||
|
||||
private final StaffDepartmentCacheRepository departmentRepository;
|
||||
|
||||
public DataInitializer(StaffDepartmentCacheRepository departmentRepository) {
|
||||
this.departmentRepository = departmentRepository;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void run(String... args) {
|
||||
if (departmentRepository.count() > 0) {
|
||||
return;
|
||||
}
|
||||
seed(1001L, "技术部", 1L, 2);
|
||||
seed(1002L, "产品部", 1L, 2);
|
||||
seed(1003L, "人事部", 1L, 2);
|
||||
}
|
||||
|
||||
private void seed(Long id, String name, Long parentId, int level) {
|
||||
StaffDepartmentCache dept = new StaffDepartmentCache();
|
||||
dept.setId(id);
|
||||
dept.setName(name);
|
||||
dept.setParentId(parentId);
|
||||
dept.setLevel(level);
|
||||
dept.setEmployeeCount(0);
|
||||
dept.setStatus("active");
|
||||
departmentRepository.save(dept);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,16 @@
|
||||
package com.hr.staff.service;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* 对接 org-mgr / 流程中心的轻量客户端桩。
|
||||
* 用于删除员工前检查是否存在未完结的资产/流程事项。
|
||||
* 当前默认返回 false(无未完结事项),生产环境可替换为真实远程调用。
|
||||
*/
|
||||
@Component
|
||||
public class ExternalProcessClient {
|
||||
|
||||
public boolean hasUnfinishedProcess(Long employeeId) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
638
src/main/java/com/hr/staff/service/StaffService.java
Normal file
638
src/main/java/com/hr/staff/service/StaffService.java
Normal file
@ -0,0 +1,638 @@
|
||||
package com.hr.staff.service;
|
||||
|
||||
import com.hr.staff.common.BusinessException;
|
||||
import com.hr.staff.common.MaskUtil;
|
||||
import com.hr.staff.common.PageResult;
|
||||
import com.hr.staff.dto.AuditLogItem;
|
||||
import com.hr.staff.dto.BatchDeleteResult;
|
||||
import com.hr.staff.dto.ChangeLogItem;
|
||||
import com.hr.staff.dto.CheckEmployeeNoResult;
|
||||
import com.hr.staff.dto.DepartmentItem;
|
||||
import com.hr.staff.dto.ExtraDto;
|
||||
import com.hr.staff.dto.StaffBrief;
|
||||
import com.hr.staff.dto.StaffCreateRequest;
|
||||
import com.hr.staff.dto.StaffDetailResponse;
|
||||
import com.hr.staff.dto.StaffListItem;
|
||||
import com.hr.staff.dto.StaffUpdateRequest;
|
||||
import com.hr.staff.entity.StaffAuditLog;
|
||||
import com.hr.staff.entity.StaffChangeLog;
|
||||
import com.hr.staff.entity.StaffDepartmentCache;
|
||||
import com.hr.staff.entity.StaffEmployee;
|
||||
import com.hr.staff.entity.StaffEmployeeExtra;
|
||||
import com.hr.staff.repository.StaffAuditLogRepository;
|
||||
import com.hr.staff.repository.StaffChangeLogRepository;
|
||||
import com.hr.staff.repository.StaffDepartmentCacheRepository;
|
||||
import com.hr.staff.repository.StaffEmployeeExtraRepository;
|
||||
import com.hr.staff.repository.StaffEmployeeRepository;
|
||||
import com.hr.staff.security.AesCipher;
|
||||
import com.hr.staff.security.AuthUser;
|
||||
import jakarta.persistence.criteria.Predicate;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.data.jpa.domain.Specification;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.context.request.RequestContextHolder;
|
||||
import org.springframework.web.context.request.ServletRequestAttributes;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* 员工管理核心业务逻辑。
|
||||
*/
|
||||
@Service
|
||||
public class StaffService {
|
||||
|
||||
private static final Set<String> CREATE_STATUS = Set.of("active", "probation");
|
||||
private static final Set<String> ALL_STATUS = Set.of("active", "probation", "inactive");
|
||||
private static final Pattern ID_NUMBER_PATTERN =
|
||||
Pattern.compile("^[1-9]\\d{5}(18|19|20)\\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\\d|3[01])\\d{3}[0-9Xx]$");
|
||||
|
||||
private final StaffEmployeeRepository employeeRepository;
|
||||
private final StaffEmployeeExtraRepository extraRepository;
|
||||
private final StaffChangeLogRepository changeLogRepository;
|
||||
private final StaffAuditLogRepository auditLogRepository;
|
||||
private final StaffDepartmentCacheRepository departmentRepository;
|
||||
private final AesCipher aesCipher;
|
||||
private final ExternalProcessClient externalProcessClient;
|
||||
|
||||
public StaffService(StaffEmployeeRepository employeeRepository,
|
||||
StaffEmployeeExtraRepository extraRepository,
|
||||
StaffChangeLogRepository changeLogRepository,
|
||||
StaffAuditLogRepository auditLogRepository,
|
||||
StaffDepartmentCacheRepository departmentRepository,
|
||||
AesCipher aesCipher,
|
||||
ExternalProcessClient externalProcessClient) {
|
||||
this.employeeRepository = employeeRepository;
|
||||
this.extraRepository = extraRepository;
|
||||
this.changeLogRepository = changeLogRepository;
|
||||
this.auditLogRepository = auditLogRepository;
|
||||
this.departmentRepository = departmentRepository;
|
||||
this.aesCipher = aesCipher;
|
||||
this.externalProcessClient = externalProcessClient;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 创建
|
||||
// ------------------------------------------------------------------
|
||||
@Transactional
|
||||
public StaffBrief create(StaffCreateRequest req, AuthUser user) {
|
||||
validateIdNumber(req.getIdNumber());
|
||||
if (employeeRepository.existsByEmployeeNo(req.getEmployeeNo())) {
|
||||
throw BusinessException.conflict("工号已存在");
|
||||
}
|
||||
String encryptedId = aesCipher.encrypt(req.getIdNumber());
|
||||
if (employeeRepository.existsByIdNumber(encryptedId)) {
|
||||
throw BusinessException.conflict("身份证号已存在");
|
||||
}
|
||||
StaffDepartmentCache dept = departmentRepository.findById(req.getDepartmentId())
|
||||
.orElseThrow(() -> BusinessException.badRequest("部门不存在"));
|
||||
String status = req.getStatus() == null ? "active" : req.getStatus();
|
||||
if (!CREATE_STATUS.contains(status)) {
|
||||
throw BusinessException.badRequest("员工状态不合法,仅支持 active/probation");
|
||||
}
|
||||
|
||||
StaffEmployee employee = new StaffEmployee();
|
||||
employee.setEmployeeNo(req.getEmployeeNo());
|
||||
employee.setName(req.getName());
|
||||
employee.setIdNumber(encryptedId);
|
||||
employee.setGender(req.getGender());
|
||||
employee.setDepartmentId(req.getDepartmentId());
|
||||
employee.setDepartmentName(dept.getName());
|
||||
employee.setPosition(req.getPosition());
|
||||
employee.setHireDate(req.getHireDate());
|
||||
employee.setPhone(req.getPhone());
|
||||
employee.setEmail(req.getEmail());
|
||||
employee.setEmergencyContact(req.getEmergencyContact());
|
||||
employee.setEmergencyPhone(req.getEmergencyPhone());
|
||||
employee.setStatus(status);
|
||||
employee.setCreatedBy(user.id());
|
||||
employee.setUpdatedBy(user.id());
|
||||
employee = employeeRepository.save(employee);
|
||||
|
||||
saveExtra(employee.getId(), req.getExtra());
|
||||
audit("CREATE", employee.getId(), user, null, null, null);
|
||||
return new StaffBrief(employee.getId(), employee.getEmployeeNo(), employee.getName());
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 查询列表
|
||||
// ------------------------------------------------------------------
|
||||
@Transactional(readOnly = true)
|
||||
public PageResult<StaffListItem> list(String keyword, Long departmentId, String position,
|
||||
String status, LocalDate hireDateStart, LocalDate hireDateEnd,
|
||||
int page, int size, String sort, AuthUser user) {
|
||||
if (status == null || status.isBlank()) {
|
||||
status = "active";
|
||||
}
|
||||
Specification<StaffEmployee> spec = buildListSpec(keyword, departmentId, position, status,
|
||||
hireDateStart, hireDateEnd, user);
|
||||
Pageable pageable = PageRequest.of(Math.max(0, page - 1), clampSize(size), parseSort(sort));
|
||||
Page<StaffEmployee> result = employeeRepository.findAll(spec, pageable);
|
||||
List<StaffListItem> items = result.getContent().stream().map(this::toListItem).toList();
|
||||
return PageResult.of(result, items);
|
||||
}
|
||||
|
||||
private Specification<StaffEmployee> buildListSpec(String keyword, Long departmentId,
|
||||
String position, String status,
|
||||
LocalDate hireDateStart, LocalDate hireDateEnd,
|
||||
AuthUser user) {
|
||||
return (root, query, cb) -> {
|
||||
List<Predicate> predicates = new ArrayList<>();
|
||||
if (keyword != null && !keyword.isBlank()) {
|
||||
predicates.add(cb.or(
|
||||
cb.like(root.get("name"), "%" + keyword + "%"),
|
||||
cb.equal(root.get("employeeNo"), keyword)
|
||||
));
|
||||
}
|
||||
if (departmentId != null) {
|
||||
predicates.add(cb.equal(root.get("departmentId"), departmentId));
|
||||
}
|
||||
if (position != null && !position.isBlank()) {
|
||||
predicates.add(cb.like(root.get("position"), "%" + position + "%"));
|
||||
}
|
||||
if (status != null && !status.isBlank()) {
|
||||
predicates.add(cb.equal(root.get("status"), status));
|
||||
}
|
||||
if (hireDateStart != null) {
|
||||
predicates.add(cb.greaterThanOrEqualTo(root.get("hireDate"), hireDateStart));
|
||||
}
|
||||
if (hireDateEnd != null) {
|
||||
predicates.add(cb.lessThanOrEqualTo(root.get("hireDate"), hireDateEnd));
|
||||
}
|
||||
// 普通员工仅能查看本部门数据
|
||||
if (user.isEmployee() && user.departmentId() != null) {
|
||||
predicates.add(cb.equal(root.get("departmentId"), user.departmentId()));
|
||||
}
|
||||
return cb.and(predicates.toArray(new Predicate[0]));
|
||||
};
|
||||
}
|
||||
|
||||
private StaffListItem toListItem(StaffEmployee e) {
|
||||
return StaffListItem.builder()
|
||||
.id(e.getId())
|
||||
.employeeNo(e.getEmployeeNo())
|
||||
.name(e.getName())
|
||||
.gender(e.getGender())
|
||||
.departmentId(e.getDepartmentId())
|
||||
.departmentName(e.getDepartmentName())
|
||||
.position(e.getPosition())
|
||||
.hireDate(e.getHireDate())
|
||||
.phone(MaskUtil.maskPhone(e.getPhone()))
|
||||
.email(e.getEmail())
|
||||
.status(e.getStatus())
|
||||
.createdAt(e.getCreatedAt())
|
||||
.build();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 查询详情
|
||||
// ------------------------------------------------------------------
|
||||
@Transactional(readOnly = true)
|
||||
public StaffDetailResponse detail(Long id, AuthUser user) {
|
||||
StaffEmployee employee = getEmployee(id);
|
||||
// 普通员工仅可查看本部门同事(含本人)
|
||||
if (user.isEmployee() && !Objects.equals(user.departmentId(), employee.getDepartmentId())) {
|
||||
throw BusinessException.forbidden("仅可查看本部门同事详情");
|
||||
}
|
||||
StaffEmployeeExtra extra = extraRepository.findByEmployeeId(id).orElse(null);
|
||||
return toDetail(employee, extra, user);
|
||||
}
|
||||
|
||||
private StaffDetailResponse toDetail(StaffEmployee e, StaffEmployeeExtra extra, AuthUser user) {
|
||||
boolean adminOrHr = user.isAdminOrHr();
|
||||
String plainIdNumber = aesCipher.decrypt(e.getIdNumber());
|
||||
ExtraDto extraDto = toExtraDto(extra);
|
||||
if (!adminOrHr && extraDto != null) {
|
||||
extraDto.setTechnicalLevel(null);
|
||||
extraDto.setSalaryGrade(null);
|
||||
extraDto.setRemark("");
|
||||
}
|
||||
return StaffDetailResponse.builder()
|
||||
.id(e.getId())
|
||||
.employeeNo(e.getEmployeeNo())
|
||||
.name(e.getName())
|
||||
.idNumber(MaskUtil.maskIdNumber(plainIdNumber))
|
||||
.gender(e.getGender())
|
||||
.departmentId(e.getDepartmentId())
|
||||
.departmentName(e.getDepartmentName())
|
||||
.position(e.getPosition())
|
||||
.hireDate(e.getHireDate())
|
||||
.phone(MaskUtil.maskPhone(e.getPhone()))
|
||||
.email(e.getEmail())
|
||||
.emergencyContact(e.getEmergencyContact())
|
||||
.emergencyPhone(adminOrHr ? e.getEmergencyPhone() : MaskUtil.maskPhone(e.getEmergencyPhone()))
|
||||
.status(e.getStatus())
|
||||
.extra(extraDto)
|
||||
.createdAt(e.getCreatedAt())
|
||||
.updatedAt(e.getUpdatedAt())
|
||||
.build();
|
||||
}
|
||||
|
||||
private ExtraDto toExtraDto(StaffEmployeeExtra extra) {
|
||||
if (extra == null) {
|
||||
return ExtraDto.builder().build();
|
||||
}
|
||||
return ExtraDto.builder()
|
||||
.education(extra.getEducation())
|
||||
.major(extra.getMajor())
|
||||
.school(extra.getSchool())
|
||||
.graduationDate(extra.getGraduationDate())
|
||||
.previousCompany(extra.getPreviousCompany())
|
||||
.workYears(extra.getWorkYears())
|
||||
.technicalLevel(extra.getTechnicalLevel())
|
||||
.salaryGrade(extra.getSalaryGrade())
|
||||
.contractType(extra.getContractType())
|
||||
.contractStart(extra.getContractStart())
|
||||
.contractEnd(extra.getContractEnd())
|
||||
.probationEnd(extra.getProbationEnd())
|
||||
.address(extra.getAddress())
|
||||
.remark(extra.getRemark())
|
||||
.build();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 更新
|
||||
// ------------------------------------------------------------------
|
||||
@Transactional
|
||||
public StaffDetailResponse update(Long id, StaffUpdateRequest req, AuthUser user) {
|
||||
StaffEmployee employee = getEmployee(id);
|
||||
boolean adminOrHr = user.isAdminOrHr();
|
||||
if (adminOrHr) {
|
||||
applyAdminUpdate(employee, req, user);
|
||||
} else {
|
||||
if (!Objects.equals(user.id(), employee.getId())) {
|
||||
throw BusinessException.forbidden("普通员工仅可修改本人的联系方式");
|
||||
}
|
||||
applyEmployeeSelfUpdate(employee, req);
|
||||
}
|
||||
employee.setUpdatedBy(user.id());
|
||||
employee = employeeRepository.save(employee);
|
||||
|
||||
if (req.getExtra() != null) {
|
||||
StaffEmployeeExtra extra = extraRepository.findByEmployeeId(id)
|
||||
.orElseGet(StaffEmployeeExtra::new);
|
||||
if (extra.getEmployeeId() == null) {
|
||||
extra.setEmployeeId(id);
|
||||
}
|
||||
if (adminOrHr) {
|
||||
applyExtra(extra, req.getExtra());
|
||||
} else if (req.getExtra().getAddress() != null) {
|
||||
extra.setAddress(req.getExtra().getAddress());
|
||||
}
|
||||
extraRepository.save(extra);
|
||||
}
|
||||
audit("UPDATE", id, user, null, null, null);
|
||||
return detail(id, user);
|
||||
}
|
||||
|
||||
private void applyAdminUpdate(StaffEmployee e, StaffUpdateRequest req, AuthUser user) {
|
||||
if (req.getDepartmentId() != null && !req.getDepartmentId().equals(e.getDepartmentId())) {
|
||||
StaffDepartmentCache dept = departmentRepository.findById(req.getDepartmentId())
|
||||
.orElseThrow(() -> BusinessException.badRequest("部门不存在"));
|
||||
changeLog(e.getId(), "DEPT_CHANGE",
|
||||
String.valueOf(e.getDepartmentId()), String.valueOf(req.getDepartmentId()), user, null);
|
||||
e.setDepartmentId(req.getDepartmentId());
|
||||
e.setDepartmentName(dept.getName());
|
||||
}
|
||||
if (req.getPosition() != null && !req.getPosition().equals(e.getPosition())) {
|
||||
changeLog(e.getId(), "POSITION_CHANGE", e.getPosition(), req.getPosition(), user, null);
|
||||
e.setPosition(req.getPosition());
|
||||
}
|
||||
if (req.getStatus() != null) {
|
||||
if (!ALL_STATUS.contains(req.getStatus())) {
|
||||
throw BusinessException.badRequest("员工状态不合法");
|
||||
}
|
||||
if (!req.getStatus().equals(e.getStatus())) {
|
||||
changeLog(e.getId(), "STATUS_CHANGE", e.getStatus(), req.getStatus(), user, null);
|
||||
e.setStatus(req.getStatus());
|
||||
}
|
||||
}
|
||||
if (req.getHireDate() != null) {
|
||||
e.setHireDate(req.getHireDate());
|
||||
}
|
||||
if (req.getPhone() != null) {
|
||||
e.setPhone(req.getPhone());
|
||||
}
|
||||
if (req.getEmail() != null) {
|
||||
e.setEmail(req.getEmail());
|
||||
}
|
||||
if (req.getEmergencyContact() != null) {
|
||||
e.setEmergencyContact(req.getEmergencyContact());
|
||||
}
|
||||
if (req.getEmergencyPhone() != null) {
|
||||
e.setEmergencyPhone(req.getEmergencyPhone());
|
||||
}
|
||||
if (req.getGender() != null) {
|
||||
e.setGender(req.getGender());
|
||||
}
|
||||
if (req.getExtra() != null && req.getExtra().getTechnicalLevel() != null) {
|
||||
StaffEmployeeExtra old = extraRepository.findByEmployeeId(e.getId()).orElse(null);
|
||||
String oldLevel = old == null ? null : old.getTechnicalLevel();
|
||||
if (!Objects.equals(oldLevel, req.getExtra().getTechnicalLevel())) {
|
||||
changeLog(e.getId(), "LEVEL_CHANGE", oldLevel,
|
||||
req.getExtra().getTechnicalLevel(), user, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void applyEmployeeSelfUpdate(StaffEmployee e, StaffUpdateRequest req) {
|
||||
if (req.getPhone() != null) {
|
||||
e.setPhone(req.getPhone());
|
||||
}
|
||||
if (req.getEmail() != null) {
|
||||
e.setEmail(req.getEmail());
|
||||
}
|
||||
if (req.getEmergencyContact() != null) {
|
||||
e.setEmergencyContact(req.getEmergencyContact());
|
||||
}
|
||||
if (req.getEmergencyPhone() != null) {
|
||||
e.setEmergencyPhone(req.getEmergencyPhone());
|
||||
}
|
||||
}
|
||||
|
||||
private void saveExtra(Long employeeId, ExtraDto dto) {
|
||||
StaffEmployeeExtra extra = new StaffEmployeeExtra();
|
||||
extra.setEmployeeId(employeeId);
|
||||
applyExtra(extra, dto);
|
||||
extraRepository.save(extra);
|
||||
}
|
||||
|
||||
private void applyExtra(StaffEmployeeExtra extra, ExtraDto dto) {
|
||||
if (dto == null) {
|
||||
return;
|
||||
}
|
||||
if (dto.getEducation() != null) {
|
||||
extra.setEducation(dto.getEducation());
|
||||
}
|
||||
if (dto.getMajor() != null) {
|
||||
extra.setMajor(dto.getMajor());
|
||||
}
|
||||
if (dto.getSchool() != null) {
|
||||
extra.setSchool(dto.getSchool());
|
||||
}
|
||||
if (dto.getGraduationDate() != null) {
|
||||
extra.setGraduationDate(dto.getGraduationDate());
|
||||
}
|
||||
if (dto.getPreviousCompany() != null) {
|
||||
extra.setPreviousCompany(dto.getPreviousCompany());
|
||||
}
|
||||
if (dto.getWorkYears() != null) {
|
||||
extra.setWorkYears(dto.getWorkYears());
|
||||
}
|
||||
if (dto.getTechnicalLevel() != null) {
|
||||
extra.setTechnicalLevel(dto.getTechnicalLevel());
|
||||
}
|
||||
if (dto.getSalaryGrade() != null) {
|
||||
extra.setSalaryGrade(dto.getSalaryGrade());
|
||||
}
|
||||
if (dto.getContractType() != null) {
|
||||
extra.setContractType(dto.getContractType());
|
||||
}
|
||||
if (dto.getContractStart() != null) {
|
||||
extra.setContractStart(dto.getContractStart());
|
||||
}
|
||||
if (dto.getContractEnd() != null) {
|
||||
extra.setContractEnd(dto.getContractEnd());
|
||||
}
|
||||
if (dto.getProbationEnd() != null) {
|
||||
extra.setProbationEnd(dto.getProbationEnd());
|
||||
}
|
||||
if (dto.getAddress() != null) {
|
||||
extra.setAddress(dto.getAddress());
|
||||
}
|
||||
if (dto.getRemark() != null) {
|
||||
extra.setRemark(dto.getRemark());
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 删除
|
||||
// ------------------------------------------------------------------
|
||||
@Transactional
|
||||
public Map<String, Object> delete(Long id, AuthUser user) {
|
||||
StaffEmployee employee = getEmployee(id);
|
||||
if ("inactive".equals(employee.getStatus())) {
|
||||
throw BusinessException.badRequest("该员工已删除,不可重复删除");
|
||||
}
|
||||
if (externalProcessClient.hasUnfinishedProcess(id)) {
|
||||
throw BusinessException.badRequest("该员工有关联未完结流程,无法删除");
|
||||
}
|
||||
String oldStatus = employee.getStatus();
|
||||
employee.setStatus("inactive");
|
||||
employee.setUpdatedBy(user.id());
|
||||
employeeRepository.save(employee);
|
||||
changeLog(id, "STATUS_CHANGE", oldStatus, "inactive", user, "员工离职/删除");
|
||||
audit("DELETE", id, user, "status", oldStatus, "inactive");
|
||||
|
||||
Map<String, Object> data = new LinkedHashMap<>();
|
||||
data.put("id", id);
|
||||
data.put("status", "inactive");
|
||||
data.put("updatedAt", employee.getUpdatedAt());
|
||||
return data;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public BatchDeleteResult batchDelete(List<Long> ids, AuthUser user) {
|
||||
int successCount = 0;
|
||||
List<BatchDeleteResult.FailedItem> failedList = new ArrayList<>();
|
||||
for (Long id : ids) {
|
||||
try {
|
||||
delete(id, user);
|
||||
successCount++;
|
||||
} catch (BusinessException ex) {
|
||||
failedList.add(BatchDeleteResult.FailedItem.builder()
|
||||
.id(id).reason(ex.getMessage()).build());
|
||||
}
|
||||
}
|
||||
return BatchDeleteResult.builder()
|
||||
.successCount(successCount)
|
||||
.failedList(failedList)
|
||||
.build();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 变更记录 / 操作日志
|
||||
// ------------------------------------------------------------------
|
||||
@Transactional(readOnly = true)
|
||||
public PageResult<ChangeLogItem> changeLogs(Long employeeId, String changeType, int page, int size) {
|
||||
getEmployee(employeeId);
|
||||
Specification<StaffChangeLog> spec = (root, query, cb) -> {
|
||||
List<Predicate> predicates = new ArrayList<>();
|
||||
predicates.add(cb.equal(root.get("employeeId"), employeeId));
|
||||
if (changeType != null && !changeType.isBlank()) {
|
||||
predicates.add(cb.equal(root.get("changeType"), changeType));
|
||||
}
|
||||
return cb.and(predicates.toArray(new Predicate[0]));
|
||||
};
|
||||
Pageable pageable = PageRequest.of(Math.max(0, page - 1), clampSize(size),
|
||||
Sort.by(Sort.Direction.DESC, "createdAt"));
|
||||
Page<StaffChangeLog> result = changeLogRepository.findAll(spec, pageable);
|
||||
List<ChangeLogItem> items = result.getContent().stream()
|
||||
.map(this::toChangeLogItem)
|
||||
.toList();
|
||||
return PageResult.of(result, items);
|
||||
}
|
||||
|
||||
private ChangeLogItem toChangeLogItem(StaffChangeLog log) {
|
||||
return ChangeLogItem.builder()
|
||||
.id(log.getId())
|
||||
.changeType(log.getChangeType())
|
||||
.oldValue(log.getOldValue())
|
||||
.newValue(log.getNewValue())
|
||||
.operatorId(log.getOperatorId())
|
||||
.operatorName(log.getOperatorName())
|
||||
.changeReason(log.getChangeReason())
|
||||
.createdAt(log.getCreatedAt())
|
||||
.build();
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public PageResult<AuditLogItem> auditLogs(String targetType, Long targetId, String operation,
|
||||
LocalDateTime startTime, LocalDateTime endTime,
|
||||
int page, int size) {
|
||||
Specification<StaffAuditLog> spec = (root, query, cb) -> {
|
||||
List<Predicate> predicates = new ArrayList<>();
|
||||
if (targetType != null && !targetType.isBlank()) {
|
||||
predicates.add(cb.equal(root.get("targetType"), targetType));
|
||||
}
|
||||
if (targetId != null) {
|
||||
predicates.add(cb.equal(root.get("targetId"), targetId));
|
||||
}
|
||||
if (operation != null && !operation.isBlank()) {
|
||||
predicates.add(cb.equal(root.get("operation"), operation));
|
||||
}
|
||||
if (startTime != null) {
|
||||
predicates.add(cb.greaterThanOrEqualTo(root.get("createdAt"), startTime));
|
||||
}
|
||||
if (endTime != null) {
|
||||
predicates.add(cb.lessThanOrEqualTo(root.get("createdAt"), endTime));
|
||||
}
|
||||
return cb.and(predicates.toArray(new Predicate[0]));
|
||||
};
|
||||
Pageable pageable = PageRequest.of(Math.max(0, page - 1), clampSize(size),
|
||||
Sort.by(Sort.Direction.DESC, "createdAt"));
|
||||
Page<StaffAuditLog> result = auditLogRepository.findAll(spec, pageable);
|
||||
List<AuditLogItem> items = result.getContent().stream()
|
||||
.map(this::toAuditLogItem)
|
||||
.toList();
|
||||
return PageResult.of(result, items);
|
||||
}
|
||||
|
||||
private AuditLogItem toAuditLogItem(StaffAuditLog log) {
|
||||
return AuditLogItem.builder()
|
||||
.id(log.getId())
|
||||
.targetType(log.getTargetType())
|
||||
.targetId(log.getTargetId())
|
||||
.operation(log.getOperation())
|
||||
.operatorId(log.getOperatorId())
|
||||
.operatorName(log.getOperatorName())
|
||||
.fieldName(log.getFieldName())
|
||||
.beforeValue(log.getBeforeValue())
|
||||
.afterValue(log.getAfterValue())
|
||||
.requestIp(log.getRequestIp())
|
||||
.createdAt(log.getCreatedAt())
|
||||
.build();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 工号查重 / 部门列表
|
||||
// ------------------------------------------------------------------
|
||||
@Transactional(readOnly = true)
|
||||
public CheckEmployeeNoResult checkEmployeeNo(String employeeNo, Long excludeId) {
|
||||
boolean exists = excludeId == null
|
||||
? employeeRepository.existsByEmployeeNo(employeeNo)
|
||||
: employeeRepository.existsByEmployeeNoAndIdNot(employeeNo, excludeId);
|
||||
return CheckEmployeeNoResult.builder().exists(exists).build();
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<DepartmentItem> departments() {
|
||||
return departmentRepository.findAllByOrderByIdAsc().stream()
|
||||
.map(d -> DepartmentItem.builder()
|
||||
.id(d.getId())
|
||||
.name(d.getName())
|
||||
.parentId(d.getParentId())
|
||||
.level(d.getLevel())
|
||||
.employeeCount((int) employeeRepository.countByDepartmentId(d.getId()))
|
||||
.build())
|
||||
.toList();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 私有工具方法
|
||||
// ------------------------------------------------------------------
|
||||
private StaffEmployee getEmployee(Long id) {
|
||||
return employeeRepository.findById(id)
|
||||
.orElseThrow(() -> BusinessException.notFound("员工不存在"));
|
||||
}
|
||||
|
||||
private void validateIdNumber(String idNumber) {
|
||||
if (!ID_NUMBER_PATTERN.matcher(idNumber).matches()) {
|
||||
throw BusinessException.badRequest("身份证号格式不正确");
|
||||
}
|
||||
}
|
||||
|
||||
private void changeLog(Long employeeId, String type, String oldValue, String newValue,
|
||||
AuthUser user, String reason) {
|
||||
StaffChangeLog log = new StaffChangeLog();
|
||||
log.setEmployeeId(employeeId);
|
||||
log.setChangeType(type);
|
||||
log.setOldValue(oldValue);
|
||||
log.setNewValue(newValue);
|
||||
log.setOperatorId(user.id());
|
||||
log.setOperatorName(user.username());
|
||||
log.setChangeReason(reason);
|
||||
changeLogRepository.save(log);
|
||||
}
|
||||
|
||||
private void audit(String operation, Long targetId, AuthUser user,
|
||||
String fieldName, String beforeValue, String afterValue) {
|
||||
StaffAuditLog log = new StaffAuditLog();
|
||||
log.setTargetType("EMPLOYEE");
|
||||
log.setTargetId(targetId);
|
||||
log.setOperation(operation);
|
||||
log.setOperatorId(user.id());
|
||||
log.setOperatorName(user.username());
|
||||
log.setFieldName(fieldName);
|
||||
log.setBeforeValue(beforeValue);
|
||||
log.setAfterValue(afterValue);
|
||||
ServletRequestAttributes attrs = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
|
||||
log.setRequestIp(attrs != null ? attrs.getRequest().getRemoteAddr() : null);
|
||||
log.setUserAgent(attrs != null ? attrs.getRequest().getHeader("User-Agent") : null);
|
||||
auditLogRepository.save(log);
|
||||
}
|
||||
|
||||
private Sort parseSort(String sort) {
|
||||
if (sort == null || sort.isBlank()) {
|
||||
return Sort.by(Sort.Direction.DESC, "createdAt");
|
||||
}
|
||||
List<Sort.Order> orders = new ArrayList<>();
|
||||
for (String part : sort.split(";")) {
|
||||
if (part.isBlank()) {
|
||||
continue;
|
||||
}
|
||||
String[] kv = part.trim().split(",");
|
||||
String field = kv[0].trim();
|
||||
Sort.Direction direction = kv.length > 1 && "asc".equalsIgnoreCase(kv[1].trim())
|
||||
? Sort.Direction.ASC
|
||||
: Sort.Direction.DESC;
|
||||
orders.add(new Sort.Order(direction, field));
|
||||
}
|
||||
return orders.isEmpty() ? Sort.by(Sort.Direction.DESC, "createdAt") : Sort.by(orders);
|
||||
}
|
||||
|
||||
private int clampSize(int size) {
|
||||
return Math.max(1, Math.min(size, 100));
|
||||
}
|
||||
}
|
||||
17
src/main/resources/application-dev.yml
Normal file
17
src/main/resources/application-dev.yml
Normal file
@ -0,0 +1,17 @@
|
||||
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
|
||||
13
src/main/resources/application-mysql.yml
Normal file
13
src/main/resources/application-mysql.yml
Normal file
@ -0,0 +1,13 @@
|
||||
spring:
|
||||
datasource:
|
||||
url: jdbc:mysql://${MYSQL_HOST:localhost}:3306/hr_main_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true
|
||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||
username: ${MYSQL_USER:root}
|
||||
password: ${MYSQL_PASSWORD:root}
|
||||
jpa:
|
||||
hibernate:
|
||||
ddl-auto: none
|
||||
open-in-view: false
|
||||
sql:
|
||||
init:
|
||||
mode: never
|
||||
17
src/main/resources/application.yml
Normal file
17
src/main/resources/application.yml
Normal file
@ -0,0 +1,17 @@
|
||||
spring:
|
||||
application:
|
||||
name: staff-mgr
|
||||
profiles:
|
||||
active: dev
|
||||
|
||||
server:
|
||||
port: 8080
|
||||
|
||||
app:
|
||||
jwt:
|
||||
secret: ${JWT_SECRET:staff-mgr-dev-secret-key-must-be-at-least-32-bytes}
|
||||
expiration-ms: 86400000
|
||||
|
||||
springdoc:
|
||||
swagger-ui:
|
||||
path: /swagger-ui.html
|
||||
101
src/main/resources/db/schema.sql
Normal file
101
src/main/resources/db/schema.sql
Normal file
@ -0,0 +1,101 @@
|
||||
-- 员工管理模块 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='部门信息本地缓存表';
|
||||
238
src/test/java/com/hr/staff/StaffApiIntegrationTest.java
Normal file
238
src/test/java/com/hr/staff/StaffApiIntegrationTest.java
Normal file
@ -0,0 +1,238 @@
|
||||
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);
|
||||
|
||||
@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());
|
||||
}
|
||||
|
||||
private Map<String, Object> createBody(String employeeNo) {
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("employeeNo", employeeNo);
|
||||
body.put("name", "张三");
|
||||
body.put("idNumber", "410123199001011234");
|
||||
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<String, Object> 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 {
|
||||
MvcResult result = mockMvc.perform(post("/api/v1/staff")
|
||||
.header("Authorization", "Bearer " + token)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(objectMapper.writeValueAsString(createBody(employeeNo))))
|
||||
.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();
|
||||
long id = create(admin, employeeNo);
|
||||
|
||||
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("410************234"))
|
||||
.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))))
|
||||
.andExpect(status().isConflict())
|
||||
.andExpect(jsonPath("$.code").value(409));
|
||||
}
|
||||
|
||||
@Test
|
||||
void employeeMaskingAndDataScope() throws Exception {
|
||||
String admin = token("ADMIN", null);
|
||||
String employeeNo = uniqueNo();
|
||||
long id = create(admin, employeeNo);
|
||||
|
||||
// 同部门员工:脱敏、敏感字段为 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("410************234"))
|
||||
.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<String, Object> 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<String, Object> 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));
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user