develop: 开发员工管理模块(端到端验证)重试

This commit is contained in:
Pipeline Agent 2026-08-15 01:20:09 +08:00
parent 81bb5d7a51
commit 0b5e437650
20 changed files with 1941 additions and 75 deletions

View File

@ -1,38 +1,48 @@
# staff-mgr 员工管理模块
Web 版本人事系统的员工管理后端服务,提供员工 CRUD、敏感信息脱敏、变更记录、操作审计日志、工号查重及部门缓存接口。
员工管理模块提供员工staff增删改查 REST 接口。
## 技术栈
- Spring Boot 3.2 / Spring Data JPA / Spring Security + JWT
- MySQL 8.0(生产)/ H2本地开发与测试
- Maven
- Java 17 / Spring Boot 3.2
- Spring Data JPA
- MySQL 8.0(生产)/ H2本地开发 & 测试)
- Spring Security + JWT对接 auth-mgr 的 Bearer Token
- SpringDoc OpenAPI
## 接口
基础路径:`/api/v1/staff`,除 Swagger 与 H2 控制台外均需 `Authorization: Bearer <token>`
| 方法 | 路径 | 说明 |
|------|------|------|
| POST | `/api/v1/staff` | 创建员工 |
| GET | `/api/v1/staff` | 分页列表查询 |
| GET | `/api/v1/staff/{id}` | 员工详情(按角色脱敏/数据权限) |
| PUT | `/api/v1/staff/{id}` | 部分更新员工 |
| DELETE | `/api/v1/staff/{id}` | 逻辑删除员工 |
| POST | `/api/v1/staff/batch-delete` | 批量删除 |
| GET | `/api/v1/staff/{id}/change-logs` | 变更记录 |
| GET | `/api/v1/staff/audit-logs` | 操作日志Admin 专属) |
| GET | `/api/v1/staff/check/employee-no` | 工号查重 |
| GET | `/api/v1/staff/departments` | 部门缓存列表 |
## 本地运行
## 快速开始
```bash
# 开发环境H2 内存库)
# 本地开发H2 内存库,无需外部依赖)
mvn spring-boot:run
# 生产环境MySQL
# 生产MySQL
mvn spring-boot:run -Dspring-boot.run.profiles=mysql
```
## 测试
```bash
mvn test
```
启动后:
- API 文档: http://localhost:8080/swagger-ui.html
- H2 控制台(仅 dev: http://localhost:8080/h2-console
## 接口一览
基础路径 `/api/v1/staff`,所有接口需携带 `Authorization: Bearer <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`

119
pom.xml
View File

@ -1 +1,118 @@
Maven 构建配置
<?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>

View File

@ -1 +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());
}
}

View File

@ -1,49 +1,68 @@
package com.hr.staff.common;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
/**
* 全局异常处理返回统一响应结构
* 全局异常处理将异常转换为统一响应结构
*/
@RestControllerAdvice
public class GlobalExceptionHandler {
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
@ExceptionHandler(BusinessException.class)
public ResponseEntity<ApiResponse<Void>> handleBusiness(BusinessException e) {
HttpStatus status = HttpStatus.resolve(e.getCode());
if (status == null) {
status = HttpStatus.INTERNAL_SERVER_ERROR;
}
return ResponseEntity.status(status).body(ApiResponse.error(e.getCode(), e.getMessage()));
HttpStatus status = resolveStatus(e.getCode());
return ResponseEntity.status(status)
.body(ApiResponse.of(e.getCode(), e.getMessage(), null));
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiResponse<Void>> handleValidation(MethodArgumentNotValidException e) {
String message = e.getBindingResult().getFieldErrors().stream()
.map(fe -> fe.getField() + ": " + fe.getDefaultMessage())
.findFirst()
.orElse("请求参数错误");
return ResponseEntity.badRequest().body(ApiResponse.error(400, message));
FieldError fieldError = e.getBindingResult().getFieldError();
String message = fieldError != null
? fieldError.getField() + ": " + fieldError.getDefaultMessage()
: "请求参数校验失败";
return ResponseEntity.badRequest().body(ApiResponse.of(400, message, null));
}
@ExceptionHandler(HttpMessageNotReadableException.class)
public ResponseEntity<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.error(403, "无权限"));
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(ApiResponse.of(403, "无权限访问该资源", null));
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiResponse<Void>> handleGeneric(Exception e) {
log.error("未处理异常", e);
public ResponseEntity<ApiResponse<Void>> handleOther(Exception e) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ApiResponse.error(500, "服务器内部错误"));
.body(ApiResponse.of(500, "服务器内部错误: " + e.getMessage(), null));
}
private HttpStatus resolveStatus(int code) {
return switch (code) {
case 400 -> HttpStatus.BAD_REQUEST;
case 401 -> HttpStatus.UNAUTHORIZED;
case 403 -> HttpStatus.FORBIDDEN;
case 404 -> HttpStatus.NOT_FOUND;
case 409 -> HttpStatus.CONFLICT;
default -> HttpStatus.INTERNAL_SERVER_ERROR;
};
}
}

View File

@ -1 +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);
}
}

View File

@ -1,14 +1,18 @@
package com.hr.staff.common;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.data.domain.Page;
import java.util.List;
/**
* 分页结果封装
* 分页结果封装字段命名与设计文档保持一致
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class PageResult<T> {
private List<T> content;
@ -19,15 +23,15 @@ public class PageResult<T> {
private boolean first;
private boolean last;
public static <T> PageResult<T> of(Page<T> page) {
PageResult<T> result = new PageResult<>();
result.setContent(page.getContent());
result.setTotalElements(page.getTotalElements());
result.setTotalPages(page.getTotalPages());
result.setNumber(page.getNumber());
result.setSize(page.getSize());
result.setFirst(page.isFirst());
result.setLast(page.isLast());
return result;
public static <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()
);
}
}

View File

@ -1 +1,49 @@
Spring Security 配置
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();
}
}

View File

@ -1 +1,133 @@
REST 接口层10 个接口
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());
}
}

View File

@ -1 +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();
}
}
}

View File

@ -1 +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();
}
}
}

View File

@ -1 +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();
}
}

View File

@ -1 +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();
}
}

View File

@ -1 +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();
}
}

View File

@ -1 +1,25 @@
员工仓储 + Specification 动态查询
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);
}

View File

@ -1 +1,60 @@
身份证 AES 加解密
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;
/**
* 身份证号等敏感字段加解密
* 采用 AESECB/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);
}
}
}

View File

@ -1 +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));
}
}

View File

@ -1 +1,17 @@
dev profileH2
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

View File

@ -11,8 +11,6 @@ app:
jwt:
secret: ${JWT_SECRET:staff-mgr-dev-secret-key-must-be-at-least-32-bytes}
expiration-ms: 86400000
crypto:
secret: ${CRYPTO_SECRET:staff-mgr-crypto-secret-32bytes-key}
springdoc:
swagger-ui:

View File

@ -1 +1,101 @@
H2 DDL
-- 员工管理模块 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='部门信息本地缓存表';

View File

@ -1 +1,260 @@
端到端集成测试8 用例
package com.hr.staff;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.hr.staff.security.JwtService;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.atomic.AtomicInteger;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* 员工管理模块端到端集成测试H2 内存库 + MockMvc + JWT
*/
@SpringBootTest
@AutoConfigureMockMvc
class StaffApiIntegrationTest {
private static final AtomicInteger SEQ = new AtomicInteger(1);
private static final AtomicInteger ID_SEQ = new AtomicInteger(1);
@Autowired
MockMvc mockMvc;
@Autowired
ObjectMapper objectMapper;
@Autowired
JwtService jwtService;
private String token(String role, Long deptId) {
return jwtService.generate(9000L + SEQ.get(), role.toLowerCase() + "_user", Set.of(role), deptId);
}
private String uniqueNo() {
return "EMP" + String.format("%08d", SEQ.incrementAndGet());
}
/**
* 生成符合身份证号格式且每次调用都不重复的 18 位身份证号
* 结构地区码(6) + 出生日期(8) + 顺序码(3) + 校验位(1)
*/
private String uniqueIdNumber() {
int n = ID_SEQ.getAndIncrement() % 1000;
return String.format("41012319900101%03d4", n);
}
private String maskedId(String idNumber) {
return idNumber.substring(0, 3)
+ "*".repeat(idNumber.length() - 6)
+ idNumber.substring(idNumber.length() - 3);
}
private Map<String, Object> createBody(String employeeNo, String idNumber) {
Map<String, Object> body = new LinkedHashMap<>();
body.put("employeeNo", employeeNo);
body.put("name", "张三");
body.put("idNumber", idNumber);
body.put("gender", 1);
body.put("departmentId", 1001L);
body.put("position", "Java开发工程师");
body.put("hireDate", "2024-01-10");
body.put("phone", "13800138000");
body.put("email", "zhangsan@example.com");
body.put("emergencyContact", "张父");
body.put("emergencyPhone", "13900139000");
Map<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 {
return create(token, employeeNo, uniqueIdNumber());
}
private long create(String token, String employeeNo, String idNumber) throws Exception {
MvcResult result = mockMvc.perform(post("/api/v1/staff")
.header("Authorization", "Bearer " + token)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(createBody(employeeNo, idNumber))))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.code").value(201))
.andExpect(jsonPath("$.data.employeeNo").value(employeeNo))
.andReturn();
return objectMapper.readTree(result.getResponse().getContentAsString())
.path("data").path("id").asLong();
}
@Test
void createListDetailFlow() throws Exception {
String admin = token("ADMIN", null);
String employeeNo = uniqueNo();
String idNumber = uniqueIdNumber();
long id = create(admin, employeeNo, idNumber);
mockMvc.perform(get("/api/v1/staff")
.header("Authorization", "Bearer " + admin)
.param("keyword", employeeNo))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.totalElements").value(1))
.andExpect(jsonPath("$.data.content[0].phone").value("138****8000"));
mockMvc.perform(get("/api/v1/staff/{id}", id)
.header("Authorization", "Bearer " + admin))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.idNumber").value(maskedId(idNumber)))
.andExpect(jsonPath("$.data.extra.technicalLevel").value("P6"))
.andExpect(jsonPath("$.data.emergencyPhone").value("13900139000"));
}
@Test
void duplicateEmployeeNoConflict() throws Exception {
String admin = token("ADMIN", null);
String employeeNo = uniqueNo();
create(admin, employeeNo);
mockMvc.perform(post("/api/v1/staff")
.header("Authorization", "Bearer " + admin)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(createBody(employeeNo, uniqueIdNumber()))))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.code").value(409));
}
@Test
void employeeMaskingAndDataScope() throws Exception {
String admin = token("ADMIN", null);
String employeeNo = uniqueNo();
String idNumber = uniqueIdNumber();
long id = create(admin, employeeNo, idNumber);
// 同部门员工脱敏敏感字段为 null
String sameDeptEmp = token("EMPLOYEE", 1001L);
mockMvc.perform(get("/api/v1/staff/{id}", id)
.header("Authorization", "Bearer " + sameDeptEmp))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.idNumber").value(maskedId(idNumber)))
.andExpect(jsonPath("$.data.extra.technicalLevel").isEmpty())
.andExpect(jsonPath("$.data.emergencyPhone").value("139****9000"));
// 跨部门员工禁止查看
String crossDeptEmp = token("EMPLOYEE", 1002L);
mockMvc.perform(get("/api/v1/staff/{id}", id)
.header("Authorization", "Bearer " + crossDeptEmp))
.andExpect(status().isForbidden());
}
@Test
void updateAndChangeLog() throws Exception {
String admin = token("ADMIN", null);
String hr = token("HR", null);
String employeeNo = uniqueNo();
long id = create(admin, employeeNo);
Map<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));
}
}