diff --git a/README.md b/README.md index a63d54f..50ef0ba 100644 --- a/README.md +++ b/README.md @@ -1,48 +1 @@ -# 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 `。 - -| 方法 | 路径 | 说明 | 权限 | -|------|------|------|------| -| 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`。 +见仓库 staff-mgr \ No newline at end of file diff --git a/pom.xml b/pom.xml index f14c402..50ef0ba 100644 --- a/pom.xml +++ b/pom.xml @@ -1,118 +1 @@ - - - 4.0.0 - - - org.springframework.boot - spring-boot-starter-parent - 3.2.5 - - - - com.hr - staff-mgr - 0.1.0-SNAPSHOT - staff-mgr - 员工管理模块 - staff CRUD REST API - - - 17 - 0.12.6 - 2.5.0 - - - - - org.springframework.boot - spring-boot-starter-web - - - org.springframework.boot - spring-boot-starter-data-jpa - - - org.springframework.boot - spring-boot-starter-validation - - - org.springframework.boot - spring-boot-starter-security - - - - - io.jsonwebtoken - jjwt-api - ${jjwt.version} - - - io.jsonwebtoken - jjwt-impl - ${jjwt.version} - runtime - - - io.jsonwebtoken - jjwt-jackson - ${jjwt.version} - runtime - - - - - org.springdoc - springdoc-openapi-starter-webmvc-ui - ${springdoc.version} - - - - - com.mysql - mysql-connector-j - runtime - - - com.h2database - h2 - runtime - - - - - org.projectlombok - lombok - true - - - - - org.springframework.boot - spring-boot-starter-test - test - - - org.springframework.security - spring-security-test - test - - - - - - - org.springframework.boot - spring-boot-maven-plugin - - - - org.projectlombok - lombok - - - - - - - +见仓库 staff-mgr \ No newline at end of file diff --git a/src/main/java/com/hr/staff/common/ApiResponse.java b/src/main/java/com/hr/staff/common/ApiResponse.java index d7057ac..50ef0ba 100644 --- a/src/main/java/com/hr/staff/common/ApiResponse.java +++ b/src/main/java/com/hr/staff/common/ApiResponse.java @@ -1,27 +1 @@ -package com.hr.staff.common; - -import lombok.AllArgsConstructor; -import lombok.Data; -import lombok.NoArgsConstructor; - -/** - * 统一 API 响应结构。 - */ -@Data -@NoArgsConstructor -@AllArgsConstructor -public class ApiResponse { - - private int code; - private String message; - private T data; - private long timestamp = System.currentTimeMillis(); - - public static ApiResponse ok(T data) { - return new ApiResponse<>(200, "success", data, System.currentTimeMillis()); - } - - public static ApiResponse of(int code, String message, T data) { - return new ApiResponse<>(code, message, data, System.currentTimeMillis()); - } -} +见仓库 staff-mgr \ No newline at end of file diff --git a/src/main/java/com/hr/staff/common/GlobalExceptionHandler.java b/src/main/java/com/hr/staff/common/GlobalExceptionHandler.java index 062cc72..50ef0ba 100644 --- a/src/main/java/com/hr/staff/common/GlobalExceptionHandler.java +++ b/src/main/java/com/hr/staff/common/GlobalExceptionHandler.java @@ -1,68 +1 @@ -package com.hr.staff.common; - -import org.springframework.dao.DataIntegrityViolationException; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.http.converter.HttpMessageNotReadableException; -import org.springframework.security.access.AccessDeniedException; -import org.springframework.validation.FieldError; -import org.springframework.web.bind.MethodArgumentNotValidException; -import org.springframework.web.bind.annotation.ExceptionHandler; -import org.springframework.web.bind.annotation.RestControllerAdvice; - -/** - * 全局异常处理,将异常转换为统一响应结构。 - */ -@RestControllerAdvice -public class GlobalExceptionHandler { - - @ExceptionHandler(BusinessException.class) - public ResponseEntity> handleBusiness(BusinessException e) { - HttpStatus status = resolveStatus(e.getCode()); - return ResponseEntity.status(status) - .body(ApiResponse.of(e.getCode(), e.getMessage(), null)); - } - - @ExceptionHandler(MethodArgumentNotValidException.class) - public ResponseEntity> handleValidation(MethodArgumentNotValidException e) { - FieldError fieldError = e.getBindingResult().getFieldError(); - String message = fieldError != null - ? fieldError.getField() + ": " + fieldError.getDefaultMessage() - : "请求参数校验失败"; - return ResponseEntity.badRequest().body(ApiResponse.of(400, message, null)); - } - - @ExceptionHandler(HttpMessageNotReadableException.class) - public ResponseEntity> handleUnreadable(HttpMessageNotReadableException e) { - return ResponseEntity.badRequest().body(ApiResponse.of(400, "请求参数格式错误", null)); - } - - @ExceptionHandler(DataIntegrityViolationException.class) - public ResponseEntity> handleConflict(DataIntegrityViolationException e) { - return ResponseEntity.status(HttpStatus.CONFLICT) - .body(ApiResponse.of(409, "数据冲突,请检查工号或身份证号是否重复", null)); - } - - @ExceptionHandler(AccessDeniedException.class) - public ResponseEntity> handleAccessDenied(AccessDeniedException e) { - return ResponseEntity.status(HttpStatus.FORBIDDEN) - .body(ApiResponse.of(403, "无权限访问该资源", null)); - } - - @ExceptionHandler(Exception.class) - public ResponseEntity> handleOther(Exception e) { - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) - .body(ApiResponse.of(500, "服务器内部错误: " + e.getMessage(), null)); - } - - private HttpStatus resolveStatus(int code) { - return switch (code) { - case 400 -> HttpStatus.BAD_REQUEST; - case 401 -> HttpStatus.UNAUTHORIZED; - case 403 -> HttpStatus.FORBIDDEN; - case 404 -> HttpStatus.NOT_FOUND; - case 409 -> HttpStatus.CONFLICT; - default -> HttpStatus.INTERNAL_SERVER_ERROR; - }; - } -} +见仓库 staff-mgr \ No newline at end of file diff --git a/src/main/java/com/hr/staff/common/MaskUtil.java b/src/main/java/com/hr/staff/common/MaskUtil.java index 536f7c3..50ef0ba 100644 --- a/src/main/java/com/hr/staff/common/MaskUtil.java +++ b/src/main/java/com/hr/staff/common/MaskUtil.java @@ -1,38 +1 @@ -package com.hr.staff.common; - -/** - * 敏感信息脱敏工具。 - */ -public final class MaskUtil { - - private MaskUtil() { - } - - /** - * 身份证号:保留前 3 位与后 3 位,中间以 * 填充。 - */ - public static String maskIdNumber(String value) { - if (value == null) { - return null; - } - if (value.length() <= 6) { - return "***"; - } - return value.substring(0, 3) - + "*".repeat(value.length() - 6) - + value.substring(value.length() - 3); - } - - /** - * 手机号:保留前 3 位与后 4 位,中间 4 位替换为 *。 - */ - public static String maskPhone(String value) { - if (value == null || value.isEmpty()) { - return value; - } - if (value.length() < 7) { - return "****"; - } - return value.substring(0, 3) + "****" + value.substring(value.length() - 4); - } -} +见仓库 staff-mgr \ No newline at end of file diff --git a/src/main/java/com/hr/staff/common/PageResult.java b/src/main/java/com/hr/staff/common/PageResult.java index 0750ccc..50ef0ba 100644 --- a/src/main/java/com/hr/staff/common/PageResult.java +++ b/src/main/java/com/hr/staff/common/PageResult.java @@ -1,37 +1 @@ -package com.hr.staff.common; - -import lombok.AllArgsConstructor; -import lombok.Data; -import lombok.NoArgsConstructor; -import org.springframework.data.domain.Page; - -import java.util.List; - -/** - * 分页结果封装,字段命名与设计文档保持一致。 - */ -@Data -@NoArgsConstructor -@AllArgsConstructor -public class PageResult { - - private List content; - private long totalElements; - private int totalPages; - private int number; - private int size; - private boolean first; - private boolean last; - - public static PageResult of(Page page, List content) { - return new PageResult<>( - content, - page.getTotalElements(), - page.getTotalPages(), - page.getNumber(), - page.getSize(), - page.isFirst(), - page.isLast() - ); - } -} +见仓库 staff-mgr \ No newline at end of file diff --git a/src/main/java/com/hr/staff/config/JwtAuthenticationFilter.java b/src/main/java/com/hr/staff/config/JwtAuthenticationFilter.java index a997f6b..50ef0ba 100644 --- a/src/main/java/com/hr/staff/config/JwtAuthenticationFilter.java +++ b/src/main/java/com/hr/staff/config/JwtAuthenticationFilter.java @@ -1,51 +1 @@ -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 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); - } -} +见仓库 staff-mgr \ No newline at end of file diff --git a/src/main/java/com/hr/staff/config/SecurityConfig.java b/src/main/java/com/hr/staff/config/SecurityConfig.java index 79e9ba2..50ef0ba 100644 --- a/src/main/java/com/hr/staff/config/SecurityConfig.java +++ b/src/main/java/com/hr/staff/config/SecurityConfig.java @@ -1,49 +1 @@ -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(); - } -} +见仓库 staff-mgr \ No newline at end of file diff --git a/src/main/java/com/hr/staff/controller/StaffController.java b/src/main/java/com/hr/staff/controller/StaffController.java index 79c564d..50ef0ba 100644 --- a/src/main/java/com/hr/staff/controller/StaffController.java +++ b/src/main/java/com/hr/staff/controller/StaffController.java @@ -1,133 +1 @@ -package com.hr.staff.controller; - -import com.hr.staff.common.ApiResponse; -import com.hr.staff.common.PageResult; -import com.hr.staff.dto.AuditLogItem; -import com.hr.staff.dto.BatchDeleteRequest; -import com.hr.staff.dto.BatchDeleteResult; -import com.hr.staff.dto.ChangeLogItem; -import com.hr.staff.dto.CheckEmployeeNoResult; -import com.hr.staff.dto.DepartmentItem; -import com.hr.staff.dto.StaffBrief; -import com.hr.staff.dto.StaffCreateRequest; -import com.hr.staff.dto.StaffDetailResponse; -import com.hr.staff.dto.StaffListItem; -import com.hr.staff.dto.StaffUpdateRequest; -import com.hr.staff.security.SecurityUtils; -import com.hr.staff.service.StaffService; -import jakarta.validation.Valid; -import org.springframework.format.annotation.DateTimeFormat; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.security.access.prepost.PreAuthorize; -import org.springframework.web.bind.annotation.DeleteMapping; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.PutMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; - -import java.time.LocalDate; -import java.time.LocalDateTime; -import java.util.List; -import java.util.Map; - -/** - * 员工管理 REST 接口,基础路径 /api/v1/staff。 - */ -@RestController -@RequestMapping("/api/v1/staff") -public class StaffController { - - private final StaffService staffService; - - public StaffController(StaffService staffService) { - this.staffService = staffService; - } - - @PostMapping - @PreAuthorize("hasAnyRole('ADMIN','HR')") - public ResponseEntity> create(@Valid @RequestBody StaffCreateRequest request) { - StaffBrief brief = staffService.create(request, SecurityUtils.currentUser()); - return ResponseEntity.status(HttpStatus.CREATED) - .body(ApiResponse.of(201, "员工创建成功", brief)); - } - - @GetMapping - public ApiResponse> list( - @RequestParam(required = false) String keyword, - @RequestParam(required = false) Long departmentId, - @RequestParam(required = false) String position, - @RequestParam(required = false) String status, - @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate hireDateStart, - @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate hireDateEnd, - @RequestParam(defaultValue = "1") int page, - @RequestParam(defaultValue = "20") int size, - @RequestParam(required = false) String sort) { - return ApiResponse.ok(staffService.list(keyword, departmentId, position, status, - hireDateStart, hireDateEnd, page, size, sort, SecurityUtils.currentUser())); - } - - @GetMapping("/{id}") - public ApiResponse detail(@PathVariable Long id) { - return ApiResponse.ok(staffService.detail(id, SecurityUtils.currentUser())); - } - - @PutMapping("/{id}") - public ApiResponse update(@PathVariable Long id, - @Valid @RequestBody StaffUpdateRequest request) { - return ApiResponse.ok(staffService.update(id, request, SecurityUtils.currentUser())); - } - - @DeleteMapping("/{id}") - @PreAuthorize("hasAnyRole('ADMIN','HR')") - public ApiResponse> delete(@PathVariable Long id) { - return ApiResponse.ok(staffService.delete(id, SecurityUtils.currentUser())); - } - - @PostMapping("/batch-delete") - @PreAuthorize("hasAnyRole('ADMIN','HR')") - public ApiResponse batchDelete(@Valid @RequestBody BatchDeleteRequest request) { - return ApiResponse.ok(staffService.batchDelete(request.getIds(), SecurityUtils.currentUser())); - } - - @GetMapping("/{id}/change-logs") - @PreAuthorize("hasAnyRole('ADMIN','HR')") - public ApiResponse> changeLogs( - @PathVariable Long id, - @RequestParam(required = false) String changeType, - @RequestParam(defaultValue = "1") int page, - @RequestParam(defaultValue = "20") int size) { - return ApiResponse.ok(staffService.changeLogs(id, changeType, page, size)); - } - - @GetMapping("/audit-logs") - @PreAuthorize("hasRole('ADMIN')") - public ApiResponse> auditLogs( - @RequestParam(required = false) String targetType, - @RequestParam(required = false) Long targetId, - @RequestParam(required = false) String operation, - @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime startTime, - @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime endTime, - @RequestParam(defaultValue = "1") int page, - @RequestParam(defaultValue = "20") int size) { - return ApiResponse.ok(staffService.auditLogs(targetType, targetId, operation, - startTime, endTime, page, size)); - } - - @GetMapping("/check/employee-no") - @PreAuthorize("hasAnyRole('ADMIN','HR')") - public ApiResponse checkEmployeeNo( - @RequestParam String employeeNo, - @RequestParam(required = false) Long excludeId) { - return ApiResponse.ok(staffService.checkEmployeeNo(employeeNo, excludeId)); - } - - @GetMapping("/departments") - public ApiResponse> departments() { - return ApiResponse.ok(staffService.departments()); - } -} +见仓库 staff-mgr \ No newline at end of file diff --git a/src/main/java/com/hr/staff/entity/StaffAuditLog.java b/src/main/java/com/hr/staff/entity/StaffAuditLog.java index 63668ff..50ef0ba 100644 --- a/src/main/java/com/hr/staff/entity/StaffAuditLog.java +++ b/src/main/java/com/hr/staff/entity/StaffAuditLog.java @@ -1,69 +1 @@ -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(); - } - } -} +见仓库 staff-mgr \ No newline at end of file diff --git a/src/main/java/com/hr/staff/entity/StaffChangeLog.java b/src/main/java/com/hr/staff/entity/StaffChangeLog.java index 155185b..50ef0ba 100644 --- a/src/main/java/com/hr/staff/entity/StaffChangeLog.java +++ b/src/main/java/com/hr/staff/entity/StaffChangeLog.java @@ -1,60 +1 @@ -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(); - } - } -} +见仓库 staff-mgr \ No newline at end of file diff --git a/src/main/java/com/hr/staff/entity/StaffDepartmentCache.java b/src/main/java/com/hr/staff/entity/StaffDepartmentCache.java index fc937ed..50ef0ba 100644 --- a/src/main/java/com/hr/staff/entity/StaffDepartmentCache.java +++ b/src/main/java/com/hr/staff/entity/StaffDepartmentCache.java @@ -1,58 +1 @@ -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(); - } -} +见仓库 staff-mgr \ No newline at end of file diff --git a/src/main/java/com/hr/staff/entity/StaffEmployee.java b/src/main/java/com/hr/staff/entity/StaffEmployee.java index 6ac1adb..50ef0ba 100644 --- a/src/main/java/com/hr/staff/entity/StaffEmployee.java +++ b/src/main/java/com/hr/staff/entity/StaffEmployee.java @@ -1,101 +1 @@ -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(); - } -} +见仓库 staff-mgr \ No newline at end of file diff --git a/src/main/java/com/hr/staff/entity/StaffEmployeeExtra.java b/src/main/java/com/hr/staff/entity/StaffEmployeeExtra.java index c563bcc..50ef0ba 100644 --- a/src/main/java/com/hr/staff/entity/StaffEmployeeExtra.java +++ b/src/main/java/com/hr/staff/entity/StaffEmployeeExtra.java @@ -1,97 +1 @@ -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(); - } -} +见仓库 staff-mgr \ No newline at end of file diff --git a/src/main/java/com/hr/staff/repository/StaffAuditLogRepository.java b/src/main/java/com/hr/staff/repository/StaffAuditLogRepository.java index e2c80a6..50ef0ba 100644 --- a/src/main/java/com/hr/staff/repository/StaffAuditLogRepository.java +++ b/src/main/java/com/hr/staff/repository/StaffAuditLogRepository.java @@ -1,9 +1 @@ -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, JpaSpecificationExecutor { -} +见仓库 staff-mgr \ No newline at end of file diff --git a/src/main/java/com/hr/staff/repository/StaffChangeLogRepository.java b/src/main/java/com/hr/staff/repository/StaffChangeLogRepository.java index df2d03a..50ef0ba 100644 --- a/src/main/java/com/hr/staff/repository/StaffChangeLogRepository.java +++ b/src/main/java/com/hr/staff/repository/StaffChangeLogRepository.java @@ -1,9 +1 @@ -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, JpaSpecificationExecutor { -} +见仓库 staff-mgr \ No newline at end of file diff --git a/src/main/java/com/hr/staff/repository/StaffDepartmentCacheRepository.java b/src/main/java/com/hr/staff/repository/StaffDepartmentCacheRepository.java index 9ee9054..50ef0ba 100644 --- a/src/main/java/com/hr/staff/repository/StaffDepartmentCacheRepository.java +++ b/src/main/java/com/hr/staff/repository/StaffDepartmentCacheRepository.java @@ -1,11 +1 @@ -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 { - - List findAllByOrderByIdAsc(); -} +见仓库 staff-mgr \ No newline at end of file diff --git a/src/main/java/com/hr/staff/repository/StaffEmployeeExtraRepository.java b/src/main/java/com/hr/staff/repository/StaffEmployeeExtraRepository.java index 454d1d3..50ef0ba 100644 --- a/src/main/java/com/hr/staff/repository/StaffEmployeeExtraRepository.java +++ b/src/main/java/com/hr/staff/repository/StaffEmployeeExtraRepository.java @@ -1,15 +1 @@ -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 { - - Optional findByEmployeeId(Long employeeId); - - List findByEmployeeIdIn(Collection employeeIds); -} +见仓库 staff-mgr \ No newline at end of file diff --git a/src/main/java/com/hr/staff/repository/StaffEmployeeRepository.java b/src/main/java/com/hr/staff/repository/StaffEmployeeRepository.java index 58eceab..50ef0ba 100644 --- a/src/main/java/com/hr/staff/repository/StaffEmployeeRepository.java +++ b/src/main/java/com/hr/staff/repository/StaffEmployeeRepository.java @@ -1,25 +1 @@ -package com.hr.staff.repository; - -import com.hr.staff.entity.StaffEmployee; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.JpaSpecificationExecutor; - -import java.util.Optional; - -public interface StaffEmployeeRepository - extends JpaRepository, JpaSpecificationExecutor { - - Optional findByEmployeeNo(String employeeNo); - - Optional findByIdNumber(String idNumber); - - boolean existsByEmployeeNo(String employeeNo); - - boolean existsByEmployeeNoAndIdNot(String employeeNo, Long id); - - boolean existsByIdNumber(String idNumber); - - boolean existsByIdNumberAndIdNot(String idNumber, Long id); - - long countByDepartmentId(Long departmentId); -} +见仓库 staff-mgr \ No newline at end of file diff --git a/src/main/java/com/hr/staff/security/AesCipher.java b/src/main/java/com/hr/staff/security/AesCipher.java index 78e0fa5..50ef0ba 100644 --- a/src/main/java/com/hr/staff/security/AesCipher.java +++ b/src/main/java/com/hr/staff/security/AesCipher.java @@ -1,60 +1 @@ -package com.hr.staff.security; - -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Component; - -import javax.crypto.Cipher; -import javax.crypto.spec.SecretKeySpec; -import java.nio.charset.StandardCharsets; -import java.security.MessageDigest; -import java.util.Base64; - -/** - * 身份证号等敏感字段加解密。 - * 采用 AES(ECB/PKCS5Padding)确定性加密,保证相同明文产生相同密文, - * 从而可继续依赖数据库唯一索引约束身份证号唯一性。 - * 生产环境应升级为 AES-GCM + 独立哈希列以兼顾安全与唯一性。 - */ -@Component -public class AesCipher { - - private final SecretKeySpec keySpec; - - public AesCipher(@Value("${app.crypto.secret:staff-mgr-aes-key-please-change-in-prod}") String secret) { - try { - byte[] keyBytes = MessageDigest.getInstance("SHA-256") - .digest(secret.getBytes(StandardCharsets.UTF_8)); - this.keySpec = new SecretKeySpec(keyBytes, "AES"); - } catch (Exception e) { - throw new IllegalStateException("初始化加密组件失败", e); - } - } - - public String encrypt(String plain) { - if (plain == null || plain.isEmpty()) { - return null; - } - try { - Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding"); - cipher.init(Cipher.ENCRYPT_MODE, keySpec); - byte[] encrypted = cipher.doFinal(plain.getBytes(StandardCharsets.UTF_8)); - return Base64.getEncoder().encodeToString(encrypted); - } catch (Exception e) { - throw new IllegalStateException("身份证号加密失败", e); - } - } - - public String decrypt(String cipherText) { - if (cipherText == null || cipherText.isEmpty()) { - return null; - } - try { - Cipher cipher = Cipher.getInstance("AES/ECB/PKCS5Padding"); - cipher.init(Cipher.DECRYPT_MODE, keySpec); - byte[] decrypted = cipher.doFinal(Base64.getDecoder().decode(cipherText)); - return new String(decrypted, StandardCharsets.UTF_8); - } catch (Exception e) { - throw new IllegalStateException("身份证号解密失败", e); - } - } -} +见仓库 staff-mgr \ No newline at end of file diff --git a/src/main/java/com/hr/staff/security/JwtService.java b/src/main/java/com/hr/staff/security/JwtService.java index d8e7011..50ef0ba 100644 --- a/src/main/java/com/hr/staff/security/JwtService.java +++ b/src/main/java/com/hr/staff/security/JwtService.java @@ -1,61 +1 @@ -package com.hr.staff.security; - -import io.jsonwebtoken.Claims; -import io.jsonwebtoken.JwtBuilder; -import io.jsonwebtoken.Jwts; -import io.jsonwebtoken.security.Keys; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Component; - -import javax.crypto.SecretKey; -import java.nio.charset.StandardCharsets; -import java.util.Date; -import java.util.List; -import java.util.Set; -import java.util.stream.Collectors; - -/** - * 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 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 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()); - } -} +见仓库 staff-mgr \ No newline at end of file diff --git a/src/main/java/com/hr/staff/security/SecurityUtils.java b/src/main/java/com/hr/staff/security/SecurityUtils.java index 4cef783..50ef0ba 100644 --- a/src/main/java/com/hr/staff/security/SecurityUtils.java +++ b/src/main/java/com/hr/staff/security/SecurityUtils.java @@ -1,22 +1 @@ -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 无效"); - } -} +见仓库 staff-mgr \ No newline at end of file diff --git a/src/main/java/com/hr/staff/service/StaffService.java b/src/main/java/com/hr/staff/service/StaffService.java index 28f0950..50ef0ba 100644 --- a/src/main/java/com/hr/staff/service/StaffService.java +++ b/src/main/java/com/hr/staff/service/StaffService.java @@ -1,638 +1 @@ -package com.hr.staff.service; - -import com.hr.staff.common.BusinessException; -import com.hr.staff.common.MaskUtil; -import com.hr.staff.common.PageResult; -import com.hr.staff.dto.AuditLogItem; -import com.hr.staff.dto.BatchDeleteResult; -import com.hr.staff.dto.ChangeLogItem; -import com.hr.staff.dto.CheckEmployeeNoResult; -import com.hr.staff.dto.DepartmentItem; -import com.hr.staff.dto.ExtraDto; -import com.hr.staff.dto.StaffBrief; -import com.hr.staff.dto.StaffCreateRequest; -import com.hr.staff.dto.StaffDetailResponse; -import com.hr.staff.dto.StaffListItem; -import com.hr.staff.dto.StaffUpdateRequest; -import com.hr.staff.entity.StaffAuditLog; -import com.hr.staff.entity.StaffChangeLog; -import com.hr.staff.entity.StaffDepartmentCache; -import com.hr.staff.entity.StaffEmployee; -import com.hr.staff.entity.StaffEmployeeExtra; -import com.hr.staff.repository.StaffAuditLogRepository; -import com.hr.staff.repository.StaffChangeLogRepository; -import com.hr.staff.repository.StaffDepartmentCacheRepository; -import com.hr.staff.repository.StaffEmployeeExtraRepository; -import com.hr.staff.repository.StaffEmployeeRepository; -import com.hr.staff.security.AesCipher; -import com.hr.staff.security.AuthUser; -import jakarta.persistence.criteria.Predicate; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageRequest; -import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Sort; -import org.springframework.data.jpa.domain.Specification; -import org.springframework.stereotype.Service; -import org.springframework.web.context.request.RequestContextHolder; -import org.springframework.web.context.request.ServletRequestAttributes; -import org.springframework.transaction.annotation.Transactional; - -import java.time.LocalDate; -import java.time.LocalDateTime; -import java.util.ArrayList; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.Set; -import java.util.regex.Pattern; - -/** - * 员工管理核心业务逻辑。 - */ -@Service -public class StaffService { - - private static final Set CREATE_STATUS = Set.of("active", "probation"); - private static final Set ALL_STATUS = Set.of("active", "probation", "inactive"); - private static final Pattern ID_NUMBER_PATTERN = - Pattern.compile("^[1-9]\\d{5}(18|19|20)\\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\\d|3[01])\\d{3}[0-9Xx]$"); - - private final StaffEmployeeRepository employeeRepository; - private final StaffEmployeeExtraRepository extraRepository; - private final StaffChangeLogRepository changeLogRepository; - private final StaffAuditLogRepository auditLogRepository; - private final StaffDepartmentCacheRepository departmentRepository; - private final AesCipher aesCipher; - private final ExternalProcessClient externalProcessClient; - - public StaffService(StaffEmployeeRepository employeeRepository, - StaffEmployeeExtraRepository extraRepository, - StaffChangeLogRepository changeLogRepository, - StaffAuditLogRepository auditLogRepository, - StaffDepartmentCacheRepository departmentRepository, - AesCipher aesCipher, - ExternalProcessClient externalProcessClient) { - this.employeeRepository = employeeRepository; - this.extraRepository = extraRepository; - this.changeLogRepository = changeLogRepository; - this.auditLogRepository = auditLogRepository; - this.departmentRepository = departmentRepository; - this.aesCipher = aesCipher; - this.externalProcessClient = externalProcessClient; - } - - // ------------------------------------------------------------------ - // 创建 - // ------------------------------------------------------------------ - @Transactional - public StaffBrief create(StaffCreateRequest req, AuthUser user) { - validateIdNumber(req.getIdNumber()); - if (employeeRepository.existsByEmployeeNo(req.getEmployeeNo())) { - throw BusinessException.conflict("工号已存在"); - } - String encryptedId = aesCipher.encrypt(req.getIdNumber()); - if (employeeRepository.existsByIdNumber(encryptedId)) { - throw BusinessException.conflict("身份证号已存在"); - } - StaffDepartmentCache dept = departmentRepository.findById(req.getDepartmentId()) - .orElseThrow(() -> BusinessException.badRequest("部门不存在")); - String status = req.getStatus() == null ? "active" : req.getStatus(); - if (!CREATE_STATUS.contains(status)) { - throw BusinessException.badRequest("员工状态不合法,仅支持 active/probation"); - } - - StaffEmployee employee = new StaffEmployee(); - employee.setEmployeeNo(req.getEmployeeNo()); - employee.setName(req.getName()); - employee.setIdNumber(encryptedId); - employee.setGender(req.getGender()); - employee.setDepartmentId(req.getDepartmentId()); - employee.setDepartmentName(dept.getName()); - employee.setPosition(req.getPosition()); - employee.setHireDate(req.getHireDate()); - employee.setPhone(req.getPhone()); - employee.setEmail(req.getEmail()); - employee.setEmergencyContact(req.getEmergencyContact()); - employee.setEmergencyPhone(req.getEmergencyPhone()); - employee.setStatus(status); - employee.setCreatedBy(user.id()); - employee.setUpdatedBy(user.id()); - employee = employeeRepository.save(employee); - - saveExtra(employee.getId(), req.getExtra()); - audit("CREATE", employee.getId(), user, null, null, null); - return new StaffBrief(employee.getId(), employee.getEmployeeNo(), employee.getName()); - } - - // ------------------------------------------------------------------ - // 查询列表 - // ------------------------------------------------------------------ - @Transactional(readOnly = true) - public PageResult list(String keyword, Long departmentId, String position, - String status, LocalDate hireDateStart, LocalDate hireDateEnd, - int page, int size, String sort, AuthUser user) { - if (status == null || status.isBlank()) { - status = "active"; - } - Specification spec = buildListSpec(keyword, departmentId, position, status, - hireDateStart, hireDateEnd, user); - Pageable pageable = PageRequest.of(Math.max(0, page - 1), clampSize(size), parseSort(sort)); - Page result = employeeRepository.findAll(spec, pageable); - List items = result.getContent().stream().map(this::toListItem).toList(); - return PageResult.of(result, items); - } - - private Specification buildListSpec(String keyword, Long departmentId, - String position, String status, - LocalDate hireDateStart, LocalDate hireDateEnd, - AuthUser user) { - return (root, query, cb) -> { - List predicates = new ArrayList<>(); - if (keyword != null && !keyword.isBlank()) { - predicates.add(cb.or( - cb.like(root.get("name"), "%" + keyword + "%"), - cb.equal(root.get("employeeNo"), keyword) - )); - } - if (departmentId != null) { - predicates.add(cb.equal(root.get("departmentId"), departmentId)); - } - if (position != null && !position.isBlank()) { - predicates.add(cb.like(root.get("position"), "%" + position + "%")); - } - if (status != null && !status.isBlank()) { - predicates.add(cb.equal(root.get("status"), status)); - } - if (hireDateStart != null) { - predicates.add(cb.greaterThanOrEqualTo(root.get("hireDate"), hireDateStart)); - } - if (hireDateEnd != null) { - predicates.add(cb.lessThanOrEqualTo(root.get("hireDate"), hireDateEnd)); - } - // 普通员工仅能查看本部门数据 - if (user.isEmployee() && user.departmentId() != null) { - predicates.add(cb.equal(root.get("departmentId"), user.departmentId())); - } - return cb.and(predicates.toArray(new Predicate[0])); - }; - } - - private StaffListItem toListItem(StaffEmployee e) { - return StaffListItem.builder() - .id(e.getId()) - .employeeNo(e.getEmployeeNo()) - .name(e.getName()) - .gender(e.getGender()) - .departmentId(e.getDepartmentId()) - .departmentName(e.getDepartmentName()) - .position(e.getPosition()) - .hireDate(e.getHireDate()) - .phone(MaskUtil.maskPhone(e.getPhone())) - .email(e.getEmail()) - .status(e.getStatus()) - .createdAt(e.getCreatedAt()) - .build(); - } - - // ------------------------------------------------------------------ - // 查询详情 - // ------------------------------------------------------------------ - @Transactional(readOnly = true) - public StaffDetailResponse detail(Long id, AuthUser user) { - StaffEmployee employee = getEmployee(id); - // 普通员工仅可查看本部门同事(含本人) - if (user.isEmployee() && !Objects.equals(user.departmentId(), employee.getDepartmentId())) { - throw BusinessException.forbidden("仅可查看本部门同事详情"); - } - StaffEmployeeExtra extra = extraRepository.findByEmployeeId(id).orElse(null); - return toDetail(employee, extra, user); - } - - private StaffDetailResponse toDetail(StaffEmployee e, StaffEmployeeExtra extra, AuthUser user) { - boolean adminOrHr = user.isAdminOrHr(); - String plainIdNumber = aesCipher.decrypt(e.getIdNumber()); - ExtraDto extraDto = toExtraDto(extra); - if (!adminOrHr && extraDto != null) { - extraDto.setTechnicalLevel(null); - extraDto.setSalaryGrade(null); - extraDto.setRemark(""); - } - return StaffDetailResponse.builder() - .id(e.getId()) - .employeeNo(e.getEmployeeNo()) - .name(e.getName()) - .idNumber(MaskUtil.maskIdNumber(plainIdNumber)) - .gender(e.getGender()) - .departmentId(e.getDepartmentId()) - .departmentName(e.getDepartmentName()) - .position(e.getPosition()) - .hireDate(e.getHireDate()) - .phone(MaskUtil.maskPhone(e.getPhone())) - .email(e.getEmail()) - .emergencyContact(e.getEmergencyContact()) - .emergencyPhone(adminOrHr ? e.getEmergencyPhone() : MaskUtil.maskPhone(e.getEmergencyPhone())) - .status(e.getStatus()) - .extra(extraDto) - .createdAt(e.getCreatedAt()) - .updatedAt(e.getUpdatedAt()) - .build(); - } - - private ExtraDto toExtraDto(StaffEmployeeExtra extra) { - if (extra == null) { - return ExtraDto.builder().build(); - } - return ExtraDto.builder() - .education(extra.getEducation()) - .major(extra.getMajor()) - .school(extra.getSchool()) - .graduationDate(extra.getGraduationDate()) - .previousCompany(extra.getPreviousCompany()) - .workYears(extra.getWorkYears()) - .technicalLevel(extra.getTechnicalLevel()) - .salaryGrade(extra.getSalaryGrade()) - .contractType(extra.getContractType()) - .contractStart(extra.getContractStart()) - .contractEnd(extra.getContractEnd()) - .probationEnd(extra.getProbationEnd()) - .address(extra.getAddress()) - .remark(extra.getRemark()) - .build(); - } - - // ------------------------------------------------------------------ - // 更新 - // ------------------------------------------------------------------ - @Transactional - public StaffDetailResponse update(Long id, StaffUpdateRequest req, AuthUser user) { - StaffEmployee employee = getEmployee(id); - boolean adminOrHr = user.isAdminOrHr(); - if (adminOrHr) { - applyAdminUpdate(employee, req, user); - } else { - if (!Objects.equals(user.id(), employee.getId())) { - throw BusinessException.forbidden("普通员工仅可修改本人的联系方式"); - } - applyEmployeeSelfUpdate(employee, req); - } - employee.setUpdatedBy(user.id()); - employee = employeeRepository.save(employee); - - if (req.getExtra() != null) { - StaffEmployeeExtra extra = extraRepository.findByEmployeeId(id) - .orElseGet(StaffEmployeeExtra::new); - if (extra.getEmployeeId() == null) { - extra.setEmployeeId(id); - } - if (adminOrHr) { - applyExtra(extra, req.getExtra()); - } else if (req.getExtra().getAddress() != null) { - extra.setAddress(req.getExtra().getAddress()); - } - extraRepository.save(extra); - } - audit("UPDATE", id, user, null, null, null); - return detail(id, user); - } - - private void applyAdminUpdate(StaffEmployee e, StaffUpdateRequest req, AuthUser user) { - if (req.getDepartmentId() != null && !req.getDepartmentId().equals(e.getDepartmentId())) { - StaffDepartmentCache dept = departmentRepository.findById(req.getDepartmentId()) - .orElseThrow(() -> BusinessException.badRequest("部门不存在")); - changeLog(e.getId(), "DEPT_CHANGE", - String.valueOf(e.getDepartmentId()), String.valueOf(req.getDepartmentId()), user, null); - e.setDepartmentId(req.getDepartmentId()); - e.setDepartmentName(dept.getName()); - } - if (req.getPosition() != null && !req.getPosition().equals(e.getPosition())) { - changeLog(e.getId(), "POSITION_CHANGE", e.getPosition(), req.getPosition(), user, null); - e.setPosition(req.getPosition()); - } - if (req.getStatus() != null) { - if (!ALL_STATUS.contains(req.getStatus())) { - throw BusinessException.badRequest("员工状态不合法"); - } - if (!req.getStatus().equals(e.getStatus())) { - changeLog(e.getId(), "STATUS_CHANGE", e.getStatus(), req.getStatus(), user, null); - e.setStatus(req.getStatus()); - } - } - if (req.getHireDate() != null) { - e.setHireDate(req.getHireDate()); - } - if (req.getPhone() != null) { - e.setPhone(req.getPhone()); - } - if (req.getEmail() != null) { - e.setEmail(req.getEmail()); - } - if (req.getEmergencyContact() != null) { - e.setEmergencyContact(req.getEmergencyContact()); - } - if (req.getEmergencyPhone() != null) { - e.setEmergencyPhone(req.getEmergencyPhone()); - } - if (req.getGender() != null) { - e.setGender(req.getGender()); - } - if (req.getExtra() != null && req.getExtra().getTechnicalLevel() != null) { - StaffEmployeeExtra old = extraRepository.findByEmployeeId(e.getId()).orElse(null); - String oldLevel = old == null ? null : old.getTechnicalLevel(); - if (!Objects.equals(oldLevel, req.getExtra().getTechnicalLevel())) { - changeLog(e.getId(), "LEVEL_CHANGE", oldLevel, - req.getExtra().getTechnicalLevel(), user, null); - } - } - } - - private void applyEmployeeSelfUpdate(StaffEmployee e, StaffUpdateRequest req) { - if (req.getPhone() != null) { - e.setPhone(req.getPhone()); - } - if (req.getEmail() != null) { - e.setEmail(req.getEmail()); - } - if (req.getEmergencyContact() != null) { - e.setEmergencyContact(req.getEmergencyContact()); - } - if (req.getEmergencyPhone() != null) { - e.setEmergencyPhone(req.getEmergencyPhone()); - } - } - - private void saveExtra(Long employeeId, ExtraDto dto) { - StaffEmployeeExtra extra = new StaffEmployeeExtra(); - extra.setEmployeeId(employeeId); - applyExtra(extra, dto); - extraRepository.save(extra); - } - - private void applyExtra(StaffEmployeeExtra extra, ExtraDto dto) { - if (dto == null) { - return; - } - if (dto.getEducation() != null) { - extra.setEducation(dto.getEducation()); - } - if (dto.getMajor() != null) { - extra.setMajor(dto.getMajor()); - } - if (dto.getSchool() != null) { - extra.setSchool(dto.getSchool()); - } - if (dto.getGraduationDate() != null) { - extra.setGraduationDate(dto.getGraduationDate()); - } - if (dto.getPreviousCompany() != null) { - extra.setPreviousCompany(dto.getPreviousCompany()); - } - if (dto.getWorkYears() != null) { - extra.setWorkYears(dto.getWorkYears()); - } - if (dto.getTechnicalLevel() != null) { - extra.setTechnicalLevel(dto.getTechnicalLevel()); - } - if (dto.getSalaryGrade() != null) { - extra.setSalaryGrade(dto.getSalaryGrade()); - } - if (dto.getContractType() != null) { - extra.setContractType(dto.getContractType()); - } - if (dto.getContractStart() != null) { - extra.setContractStart(dto.getContractStart()); - } - if (dto.getContractEnd() != null) { - extra.setContractEnd(dto.getContractEnd()); - } - if (dto.getProbationEnd() != null) { - extra.setProbationEnd(dto.getProbationEnd()); - } - if (dto.getAddress() != null) { - extra.setAddress(dto.getAddress()); - } - if (dto.getRemark() != null) { - extra.setRemark(dto.getRemark()); - } - } - - // ------------------------------------------------------------------ - // 删除 - // ------------------------------------------------------------------ - @Transactional - public Map delete(Long id, AuthUser user) { - StaffEmployee employee = getEmployee(id); - if ("inactive".equals(employee.getStatus())) { - throw BusinessException.badRequest("该员工已删除,不可重复删除"); - } - if (externalProcessClient.hasUnfinishedProcess(id)) { - throw BusinessException.badRequest("该员工有关联未完结流程,无法删除"); - } - String oldStatus = employee.getStatus(); - employee.setStatus("inactive"); - employee.setUpdatedBy(user.id()); - employeeRepository.save(employee); - changeLog(id, "STATUS_CHANGE", oldStatus, "inactive", user, "员工离职/删除"); - audit("DELETE", id, user, "status", oldStatus, "inactive"); - - Map data = new LinkedHashMap<>(); - data.put("id", id); - data.put("status", "inactive"); - data.put("updatedAt", employee.getUpdatedAt()); - return data; - } - - @Transactional - public BatchDeleteResult batchDelete(List ids, AuthUser user) { - int successCount = 0; - List failedList = new ArrayList<>(); - for (Long id : ids) { - try { - delete(id, user); - successCount++; - } catch (BusinessException ex) { - failedList.add(BatchDeleteResult.FailedItem.builder() - .id(id).reason(ex.getMessage()).build()); - } - } - return BatchDeleteResult.builder() - .successCount(successCount) - .failedList(failedList) - .build(); - } - - // ------------------------------------------------------------------ - // 变更记录 / 操作日志 - // ------------------------------------------------------------------ - @Transactional(readOnly = true) - public PageResult changeLogs(Long employeeId, String changeType, int page, int size) { - getEmployee(employeeId); - Specification spec = (root, query, cb) -> { - List predicates = new ArrayList<>(); - predicates.add(cb.equal(root.get("employeeId"), employeeId)); - if (changeType != null && !changeType.isBlank()) { - predicates.add(cb.equal(root.get("changeType"), changeType)); - } - return cb.and(predicates.toArray(new Predicate[0])); - }; - Pageable pageable = PageRequest.of(Math.max(0, page - 1), clampSize(size), - Sort.by(Sort.Direction.DESC, "createdAt")); - Page result = changeLogRepository.findAll(spec, pageable); - List items = result.getContent().stream() - .map(this::toChangeLogItem) - .toList(); - return PageResult.of(result, items); - } - - private ChangeLogItem toChangeLogItem(StaffChangeLog log) { - return ChangeLogItem.builder() - .id(log.getId()) - .changeType(log.getChangeType()) - .oldValue(log.getOldValue()) - .newValue(log.getNewValue()) - .operatorId(log.getOperatorId()) - .operatorName(log.getOperatorName()) - .changeReason(log.getChangeReason()) - .createdAt(log.getCreatedAt()) - .build(); - } - - @Transactional(readOnly = true) - public PageResult auditLogs(String targetType, Long targetId, String operation, - LocalDateTime startTime, LocalDateTime endTime, - int page, int size) { - Specification spec = (root, query, cb) -> { - List predicates = new ArrayList<>(); - if (targetType != null && !targetType.isBlank()) { - predicates.add(cb.equal(root.get("targetType"), targetType)); - } - if (targetId != null) { - predicates.add(cb.equal(root.get("targetId"), targetId)); - } - if (operation != null && !operation.isBlank()) { - predicates.add(cb.equal(root.get("operation"), operation)); - } - if (startTime != null) { - predicates.add(cb.greaterThanOrEqualTo(root.get("createdAt"), startTime)); - } - if (endTime != null) { - predicates.add(cb.lessThanOrEqualTo(root.get("createdAt"), endTime)); - } - return cb.and(predicates.toArray(new Predicate[0])); - }; - Pageable pageable = PageRequest.of(Math.max(0, page - 1), clampSize(size), - Sort.by(Sort.Direction.DESC, "createdAt")); - Page result = auditLogRepository.findAll(spec, pageable); - List items = result.getContent().stream() - .map(this::toAuditLogItem) - .toList(); - return PageResult.of(result, items); - } - - private AuditLogItem toAuditLogItem(StaffAuditLog log) { - return AuditLogItem.builder() - .id(log.getId()) - .targetType(log.getTargetType()) - .targetId(log.getTargetId()) - .operation(log.getOperation()) - .operatorId(log.getOperatorId()) - .operatorName(log.getOperatorName()) - .fieldName(log.getFieldName()) - .beforeValue(log.getBeforeValue()) - .afterValue(log.getAfterValue()) - .requestIp(log.getRequestIp()) - .createdAt(log.getCreatedAt()) - .build(); - } - - // ------------------------------------------------------------------ - // 工号查重 / 部门列表 - // ------------------------------------------------------------------ - @Transactional(readOnly = true) - public CheckEmployeeNoResult checkEmployeeNo(String employeeNo, Long excludeId) { - boolean exists = excludeId == null - ? employeeRepository.existsByEmployeeNo(employeeNo) - : employeeRepository.existsByEmployeeNoAndIdNot(employeeNo, excludeId); - return CheckEmployeeNoResult.builder().exists(exists).build(); - } - - @Transactional(readOnly = true) - public List departments() { - return departmentRepository.findAllByOrderByIdAsc().stream() - .map(d -> DepartmentItem.builder() - .id(d.getId()) - .name(d.getName()) - .parentId(d.getParentId()) - .level(d.getLevel()) - .employeeCount((int) employeeRepository.countByDepartmentId(d.getId())) - .build()) - .toList(); - } - - // ------------------------------------------------------------------ - // 私有工具方法 - // ------------------------------------------------------------------ - private StaffEmployee getEmployee(Long id) { - return employeeRepository.findById(id) - .orElseThrow(() -> BusinessException.notFound("员工不存在")); - } - - private void validateIdNumber(String idNumber) { - if (!ID_NUMBER_PATTERN.matcher(idNumber).matches()) { - throw BusinessException.badRequest("身份证号格式不正确"); - } - } - - private void changeLog(Long employeeId, String type, String oldValue, String newValue, - AuthUser user, String reason) { - StaffChangeLog log = new StaffChangeLog(); - log.setEmployeeId(employeeId); - log.setChangeType(type); - log.setOldValue(oldValue); - log.setNewValue(newValue); - log.setOperatorId(user.id()); - log.setOperatorName(user.username()); - log.setChangeReason(reason); - changeLogRepository.save(log); - } - - private void audit(String operation, Long targetId, AuthUser user, - String fieldName, String beforeValue, String afterValue) { - StaffAuditLog log = new StaffAuditLog(); - log.setTargetType("EMPLOYEE"); - log.setTargetId(targetId); - log.setOperation(operation); - log.setOperatorId(user.id()); - log.setOperatorName(user.username()); - log.setFieldName(fieldName); - log.setBeforeValue(beforeValue); - log.setAfterValue(afterValue); - ServletRequestAttributes attrs = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); - log.setRequestIp(attrs != null ? attrs.getRequest().getRemoteAddr() : null); - log.setUserAgent(attrs != null ? attrs.getRequest().getHeader("User-Agent") : null); - auditLogRepository.save(log); - } - - private Sort parseSort(String sort) { - if (sort == null || sort.isBlank()) { - return Sort.by(Sort.Direction.DESC, "createdAt"); - } - List orders = new ArrayList<>(); - for (String part : sort.split(";")) { - if (part.isBlank()) { - continue; - } - String[] kv = part.trim().split(","); - String field = kv[0].trim(); - Sort.Direction direction = kv.length > 1 && "asc".equalsIgnoreCase(kv[1].trim()) - ? Sort.Direction.ASC - : Sort.Direction.DESC; - orders.add(new Sort.Order(direction, field)); - } - return orders.isEmpty() ? Sort.by(Sort.Direction.DESC, "createdAt") : Sort.by(orders); - } - - private int clampSize(int size) { - return Math.max(1, Math.min(size, 100)); - } -} +见仓库 staff-mgr \ No newline at end of file diff --git a/src/main/resources/db/schema.sql b/src/main/resources/db/schema.sql index a168f90..50ef0ba 100644 --- a/src/main/resources/db/schema.sql +++ b/src/main/resources/db/schema.sql @@ -1,101 +1 @@ --- 员工管理模块 MySQL 8.0 DDL(生产库手动执行) -CREATE TABLE IF NOT EXISTS staff_employee ( - id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', - employee_no VARCHAR(32) NOT NULL COMMENT '工号,唯一标识', - name VARCHAR(64) NOT NULL COMMENT '员工姓名', - id_number VARCHAR(64) NOT NULL COMMENT '身份证号,应用层加密存储', - gender TINYINT DEFAULT 0 COMMENT '性别:0-未知,1-男,2-女', - department_id BIGINT NOT NULL COMMENT '所属部门ID', - department_name VARCHAR(128) DEFAULT '' COMMENT '部门名称(冗余,加快查询)', - position VARCHAR(128) NOT NULL COMMENT '岗位名称', - hire_date DATE NOT NULL COMMENT '入职日期', - phone VARCHAR(20) DEFAULT NULL COMMENT '联系电话', - email VARCHAR(128) DEFAULT NULL COMMENT '邮箱', - emergency_contact VARCHAR(64) DEFAULT NULL COMMENT '紧急联系人姓名', - emergency_phone VARCHAR(20) DEFAULT NULL COMMENT '紧急联系人电话', - status VARCHAR(16) NOT NULL DEFAULT 'active' COMMENT '状态:active-在职,inactive-离职,probation-试用期', - created_by BIGINT DEFAULT NULL COMMENT '创建人ID', - updated_by BIGINT DEFAULT NULL COMMENT '最后更新人ID', - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', - PRIMARY KEY (id), - UNIQUE KEY uk_employee_no (employee_no), - UNIQUE KEY uk_id_number (id_number), - KEY idx_name (name), - KEY idx_department_id (department_id), - KEY idx_status (status), - KEY idx_hire_date (hire_date), - KEY idx_created_at (created_at) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='员工主表'; - -CREATE TABLE IF NOT EXISTS staff_employee_extra ( - id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', - employee_id BIGINT NOT NULL COMMENT '员工ID', - education VARCHAR(32) DEFAULT NULL COMMENT '学历', - major VARCHAR(128) DEFAULT NULL COMMENT '专业', - school VARCHAR(128) DEFAULT NULL COMMENT '毕业院校', - graduation_date DATE DEFAULT NULL COMMENT '毕业时间', - previous_company VARCHAR(128) DEFAULT NULL COMMENT '前工作单位', - work_years INT DEFAULT 0 COMMENT '总工作年限', - technical_level VARCHAR(32) DEFAULT NULL COMMENT '技术级别,仅 Admin/HR 可见', - salary_grade VARCHAR(32) DEFAULT NULL COMMENT '薪资等级,仅 Admin/HR 可见', - contract_type VARCHAR(32) DEFAULT NULL COMMENT '合同类型', - contract_start DATE DEFAULT NULL COMMENT '合同开始日期', - contract_end DATE DEFAULT NULL COMMENT '合同结束日期', - probation_end DATE DEFAULT NULL COMMENT '试用期截止日', - address VARCHAR(256) DEFAULT NULL COMMENT '现住址', - remark TEXT DEFAULT NULL COMMENT '备注', - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', - PRIMARY KEY (id), - UNIQUE KEY uk_employee_id (employee_id), - KEY idx_technical_level (technical_level), - KEY idx_contract_end (contract_end) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='员工扩展信息表'; - -CREATE TABLE IF NOT EXISTS staff_change_log ( - id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', - employee_id BIGINT NOT NULL COMMENT '员工ID', - change_type VARCHAR(32) NOT NULL COMMENT '变更类型', - old_value VARCHAR(256) DEFAULT NULL COMMENT '原值', - new_value VARCHAR(256) DEFAULT NULL COMMENT '新值', - operator_id BIGINT NOT NULL COMMENT '操作人ID', - operator_name VARCHAR(64) DEFAULT NULL COMMENT '操作人姓名', - change_reason VARCHAR(512) DEFAULT NULL COMMENT '变更原因', - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '变更时间', - PRIMARY KEY (id), - KEY idx_employee_id (employee_id), - KEY idx_change_type (change_type), - KEY idx_created_at (created_at) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='员工变更记录表'; - -CREATE TABLE IF NOT EXISTS staff_audit_log ( - id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID', - target_type VARCHAR(32) NOT NULL COMMENT '操作目标类型', - target_id BIGINT NOT NULL COMMENT '操作目标ID', - operation VARCHAR(32) NOT NULL COMMENT '操作类型:CREATE/UPDATE/DELETE/VIEW', - operator_id BIGINT NOT NULL COMMENT '操作人ID', - operator_name VARCHAR(64) DEFAULT '' COMMENT '操作人姓名', - field_name VARCHAR(64) DEFAULT NULL COMMENT '变更字段名', - before_value TEXT DEFAULT NULL COMMENT '变更前值', - after_value TEXT DEFAULT NULL COMMENT '变更后值', - request_ip VARCHAR(64) DEFAULT NULL COMMENT '请求IP', - user_agent VARCHAR(512) DEFAULT NULL COMMENT '浏览器UA', - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '操作时间', - PRIMARY KEY (id), - KEY idx_target (target_type, target_id), - KEY idx_operator_id (operator_id), - KEY idx_created_at (created_at) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='操作审计日志表'; - -CREATE TABLE IF NOT EXISTS staff_department_cache ( - id BIGINT NOT NULL COMMENT '部门ID(来自 org-mgr)', - name VARCHAR(128) NOT NULL COMMENT '部门名称', - parent_id BIGINT DEFAULT NULL COMMENT '上级部门ID', - level INT DEFAULT 0 COMMENT '层级深度', - employee_count INT DEFAULT 0 COMMENT '部门人数', - status VARCHAR(16) NOT NULL DEFAULT 'active' COMMENT '状态', - sync_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '最后同步时间', - PRIMARY KEY (id), - KEY idx_parent_id (parent_id) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='部门信息本地缓存表'; +见仓库 staff-mgr \ No newline at end of file diff --git a/src/test/java/com/hr/staff/StaffApiIntegrationTest.java b/src/test/java/com/hr/staff/StaffApiIntegrationTest.java index 9fbe959..50ef0ba 100644 --- a/src/test/java/com/hr/staff/StaffApiIntegrationTest.java +++ b/src/test/java/com/hr/staff/StaffApiIntegrationTest.java @@ -1,260 +1 @@ -package com.hr.staff; - -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.hr.staff.security.JwtService; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.http.MediaType; -import org.springframework.test.web.servlet.MockMvc; -import org.springframework.test.web.servlet.MvcResult; - -import java.util.LinkedHashMap; -import java.util.Map; -import java.util.Set; -import java.util.concurrent.atomic.AtomicInteger; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - -/** - * 员工管理模块端到端集成测试(H2 内存库 + MockMvc + JWT)。 - */ -@SpringBootTest -@AutoConfigureMockMvc -class StaffApiIntegrationTest { - - private static final AtomicInteger SEQ = new AtomicInteger(1); - private static final AtomicInteger ID_SEQ = new AtomicInteger(1); - - @Autowired - MockMvc mockMvc; - - @Autowired - ObjectMapper objectMapper; - - @Autowired - JwtService jwtService; - - private String token(String role, Long deptId) { - return jwtService.generate(9000L + SEQ.get(), role.toLowerCase() + "_user", Set.of(role), deptId); - } - - private String uniqueNo() { - return "EMP" + String.format("%08d", SEQ.incrementAndGet()); - } - - /** - * 生成符合身份证号格式且每次调用都不重复的 18 位身份证号。 - * 结构:地区码(6) + 出生日期(8) + 顺序码(3) + 校验位(1)。 - */ - private String uniqueIdNumber() { - int n = ID_SEQ.getAndIncrement() % 1000; - return String.format("41012319900101%03d4", n); - } - - private String maskedId(String idNumber) { - return idNumber.substring(0, 3) - + "*".repeat(idNumber.length() - 6) - + idNumber.substring(idNumber.length() - 3); - } - - private Map createBody(String employeeNo, String idNumber) { - Map body = new LinkedHashMap<>(); - body.put("employeeNo", employeeNo); - body.put("name", "张三"); - body.put("idNumber", idNumber); - body.put("gender", 1); - body.put("departmentId", 1001L); - body.put("position", "Java开发工程师"); - body.put("hireDate", "2024-01-10"); - body.put("phone", "13800138000"); - body.put("email", "zhangsan@example.com"); - body.put("emergencyContact", "张父"); - body.put("emergencyPhone", "13900139000"); - Map extra = new LinkedHashMap<>(); - extra.put("education", "本科"); - extra.put("technicalLevel", "P6"); - extra.put("salaryGrade", "S3"); - extra.put("address", "北京市朝阳区"); - body.put("extra", extra); - return body; - } - - private long create(String token, String employeeNo) throws Exception { - return create(token, employeeNo, uniqueIdNumber()); - } - - private long create(String token, String employeeNo, String idNumber) throws Exception { - MvcResult result = mockMvc.perform(post("/api/v1/staff") - .header("Authorization", "Bearer " + token) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(createBody(employeeNo, idNumber)))) - .andExpect(status().isCreated()) - .andExpect(jsonPath("$.code").value(201)) - .andExpect(jsonPath("$.data.employeeNo").value(employeeNo)) - .andReturn(); - return objectMapper.readTree(result.getResponse().getContentAsString()) - .path("data").path("id").asLong(); - } - - @Test - void createListDetailFlow() throws Exception { - String admin = token("ADMIN", null); - String employeeNo = uniqueNo(); - String idNumber = uniqueIdNumber(); - long id = create(admin, employeeNo, idNumber); - - mockMvc.perform(get("/api/v1/staff") - .header("Authorization", "Bearer " + admin) - .param("keyword", employeeNo)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.totalElements").value(1)) - .andExpect(jsonPath("$.data.content[0].phone").value("138****8000")); - - mockMvc.perform(get("/api/v1/staff/{id}", id) - .header("Authorization", "Bearer " + admin)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.idNumber").value(maskedId(idNumber))) - .andExpect(jsonPath("$.data.extra.technicalLevel").value("P6")) - .andExpect(jsonPath("$.data.emergencyPhone").value("13900139000")); - } - - @Test - void duplicateEmployeeNoConflict() throws Exception { - String admin = token("ADMIN", null); - String employeeNo = uniqueNo(); - create(admin, employeeNo); - - mockMvc.perform(post("/api/v1/staff") - .header("Authorization", "Bearer " + admin) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(createBody(employeeNo, uniqueIdNumber())))) - .andExpect(status().isConflict()) - .andExpect(jsonPath("$.code").value(409)); - } - - @Test - void employeeMaskingAndDataScope() throws Exception { - String admin = token("ADMIN", null); - String employeeNo = uniqueNo(); - String idNumber = uniqueIdNumber(); - long id = create(admin, employeeNo, idNumber); - - // 同部门员工:脱敏、敏感字段为 null - String sameDeptEmp = token("EMPLOYEE", 1001L); - mockMvc.perform(get("/api/v1/staff/{id}", id) - .header("Authorization", "Bearer " + sameDeptEmp)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.idNumber").value(maskedId(idNumber))) - .andExpect(jsonPath("$.data.extra.technicalLevel").isEmpty()) - .andExpect(jsonPath("$.data.emergencyPhone").value("139****9000")); - - // 跨部门员工:禁止查看 - String crossDeptEmp = token("EMPLOYEE", 1002L); - mockMvc.perform(get("/api/v1/staff/{id}", id) - .header("Authorization", "Bearer " + crossDeptEmp)) - .andExpect(status().isForbidden()); - } - - @Test - void updateAndChangeLog() throws Exception { - String admin = token("ADMIN", null); - String hr = token("HR", null); - String employeeNo = uniqueNo(); - long id = create(admin, employeeNo); - - Map update = new LinkedHashMap<>(); - update.put("position", "高级Java开发工程师"); - mockMvc.perform(put("/api/v1/staff/{id}", id) - .header("Authorization", "Bearer " + hr) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(update))) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.position").value("高级Java开发工程师")); - - mockMvc.perform(get("/api/v1/staff/{id}/change-logs", id) - .header("Authorization", "Bearer " + admin)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.content[0].changeType").value("POSITION_CHANGE")) - .andExpect(jsonPath("$.data.content[0].newValue").value("高级Java开发工程师")); - } - - @Test - void checkEmployeeNo() throws Exception { - String admin = token("ADMIN", null); - String employeeNo = uniqueNo(); - create(admin, employeeNo); - - mockMvc.perform(get("/api/v1/staff/check/employee-no") - .header("Authorization", "Bearer " + admin) - .param("employeeNo", employeeNo)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.exists").value(true)); - - mockMvc.perform(get("/api/v1/staff/check/employee-no") - .header("Authorization", "Bearer " + admin) - .param("employeeNo", "NOT_EXIST")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.exists").value(false)); - } - - @Test - void deleteFlow() throws Exception { - String admin = token("ADMIN", null); - String employeeNo = uniqueNo(); - long id = create(admin, employeeNo); - - mockMvc.perform(delete("/api/v1/staff/{id}", id) - .header("Authorization", "Bearer " + admin)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.status").value("inactive")); - - // 重复删除应失败 - mockMvc.perform(delete("/api/v1/staff/{id}", id) - .header("Authorization", "Bearer " + admin)) - .andExpect(status().isBadRequest()); - } - - @Test - void batchDeleteFlow() throws Exception { - String admin = token("ADMIN", null); - long id1 = create(admin, uniqueNo()); - long id2 = create(admin, uniqueNo()); - - Map body = new LinkedHashMap<>(); - body.put("ids", new long[]{id1, id2}); - MvcResult result = mockMvc.perform(post("/api/v1/staff/batch-delete") - .header("Authorization", "Bearer " + admin) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(body))) - .andExpect(status().isOk()) - .andReturn(); - JsonNode data = objectMapper.readTree(result.getResponse().getContentAsString()).path("data"); - assertThat(data.path("successCount").asInt()).isEqualTo(2); - assertThat(data.path("failedList").size()).isEqualTo(0); - } - - @Test - void auditLogsAndDepartments() throws Exception { - String admin = token("ADMIN", null); - create(admin, uniqueNo()); - - mockMvc.perform(get("/api/v1/staff/audit-logs") - .header("Authorization", "Bearer " + admin)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.totalElements").isNumber()); - - mockMvc.perform(get("/api/v1/staff/departments") - .header("Authorization", "Bearer " + admin)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.length()").value(3)); - } -} +见仓库 staff-mgr \ No newline at end of file