zbLiuLiu vor 7 Stunden
Commit
1613a6e3e0
100 geänderte Dateien mit 6671 neuen und 0 gelöschten Zeilen
  1. 66 0
      .github/workflows/ci.yml
  2. 14 0
      .gitignore
  3. 489 0
      AGENTS.md
  4. 119 0
      README.md
  5. 1 0
      backend/.mvn/wrapper/maven-wrapper.properties
  6. 10 0
      backend/mvnw
  7. 13 0
      backend/mvnw.cmd
  8. 167 0
      backend/pom.xml
  9. 19 0
      backend/src/main/java/com/example/hrlab/HrLabApplication.java
  10. 124 0
      backend/src/main/java/com/example/hrlab/attendance/AttendanceAdminController.java
  11. 294 0
      backend/src/main/java/com/example/hrlab/attendance/AttendanceController.java
  12. 11 0
      backend/src/main/java/com/example/hrlab/attendance/AttendanceRecordRequest.java
  13. 4 0
      backend/src/main/java/com/example/hrlab/attendance/DecisionRequest.java
  14. 10 0
      backend/src/main/java/com/example/hrlab/attendance/LeaveRequestDto.java
  15. 54 0
      backend/src/main/java/com/example/hrlab/attendance/LeaveValidator.java
  16. 8 0
      backend/src/main/java/com/example/hrlab/attendance/OvertimeRequestDto.java
  17. 9 0
      backend/src/main/java/com/example/hrlab/attendance/ShiftRequest.java
  18. 48 0
      backend/src/main/java/com/example/hrlab/audit/AuditController.java
  19. 78 0
      backend/src/main/java/com/example/hrlab/audit/AuditService.java
  20. 53 0
      backend/src/main/java/com/example/hrlab/auth/AuthController.java
  21. 153 0
      backend/src/main/java/com/example/hrlab/auth/AuthService.java
  22. 10 0
      backend/src/main/java/com/example/hrlab/auth/ExternalIdentityProvider.java
  23. 15 0
      backend/src/main/java/com/example/hrlab/auth/LocalCompatibilityIdentityProvider.java
  24. 6 0
      backend/src/main/java/com/example/hrlab/auth/LoginRequest.java
  25. 6 0
      backend/src/main/java/com/example/hrlab/auth/LoginResult.java
  26. 4 0
      backend/src/main/java/com/example/hrlab/auth/LogoutRequest.java
  27. 6 0
      backend/src/main/java/com/example/hrlab/auth/RefreshRequest.java
  28. 18 0
      backend/src/main/java/com/example/hrlab/common/ApiResponse.java
  29. 20 0
      backend/src/main/java/com/example/hrlab/common/AsyncTaskPublisher.java
  30. 36 0
      backend/src/main/java/com/example/hrlab/common/BusinessException.java
  31. 21 0
      backend/src/main/java/com/example/hrlab/common/Correlation.java
  32. 29 0
      backend/src/main/java/com/example/hrlab/common/CorrelationFilter.java
  33. 16 0
      backend/src/main/java/com/example/hrlab/common/ErrorCodes.java
  34. 68 0
      backend/src/main/java/com/example/hrlab/common/GlobalExceptionHandler.java
  35. 79 0
      backend/src/main/java/com/example/hrlab/common/JobMonitorController.java
  36. 32 0
      backend/src/main/java/com/example/hrlab/common/JsonUtils.java
  37. 6 0
      backend/src/main/java/com/example/hrlab/common/PageResponse.java
  38. 26 0
      backend/src/main/java/com/example/hrlab/common/Rows.java
  39. 37 0
      backend/src/main/java/com/example/hrlab/common/TaskWorker.java
  40. 47 0
      backend/src/main/java/com/example/hrlab/config/AppProperties.java
  41. 18 0
      backend/src/main/java/com/example/hrlab/config/ClockConfig.java
  42. 19 0
      backend/src/main/java/com/example/hrlab/config/PasswordConfig.java
  43. 79 0
      backend/src/main/java/com/example/hrlab/config/RabbitConfig.java
  44. 7 0
      backend/src/main/java/com/example/hrlab/employee/AttachmentRequest.java
  45. 204 0
      backend/src/main/java/com/example/hrlab/employee/EmployeeController.java
  46. 25 0
      backend/src/main/java/com/example/hrlab/employee/EmployeeRequest.java
  47. 8 0
      backend/src/main/java/com/example/hrlab/employee/TransferRequest.java
  48. 68 0
      backend/src/main/java/com/example/hrlab/integration/IntegrationController.java
  49. 33 0
      backend/src/main/java/com/example/hrlab/integration/IntegrationEventService.java
  50. 301 0
      backend/src/main/java/com/example/hrlab/lab/DemoDataSeeder.java
  51. 67 0
      backend/src/main/java/com/example/hrlab/onboarding/CaseCrudController.java
  52. 8 0
      backend/src/main/java/com/example/hrlab/onboarding/CaseRequest.java
  53. 64 0
      backend/src/main/java/com/example/hrlab/onboarding/ChecklistNormalizer.java
  54. 4 0
      backend/src/main/java/com/example/hrlab/onboarding/CompleteChecklistRequest.java
  55. 149 0
      backend/src/main/java/com/example/hrlab/onboarding/OffboardingController.java
  56. 200 0
      backend/src/main/java/com/example/hrlab/onboarding/OnboardingController.java
  57. 75 0
      backend/src/main/java/com/example/hrlab/organization/DepartmentEntity.java
  58. 8 0
      backend/src/main/java/com/example/hrlab/organization/DepartmentRepository.java
  59. 7 0
      backend/src/main/java/com/example/hrlab/organization/DepartmentRequest.java
  60. 23 0
      backend/src/main/java/com/example/hrlab/organization/JpaOrganizationController.java
  61. 157 0
      backend/src/main/java/com/example/hrlab/organization/OrganizationController.java
  62. 7 0
      backend/src/main/java/com/example/hrlab/organization/PositionRequest.java
  63. 43 0
      backend/src/main/java/com/example/hrlab/payroll/PayrollCalculator.java
  64. 250 0
      backend/src/main/java/com/example/hrlab/payroll/PayrollController.java
  65. 259 0
      backend/src/main/java/com/example/hrlab/payroll/PayrollFormulaEngine.java
  66. 97 0
      backend/src/main/java/com/example/hrlab/payroll/PayrollItemController.java
  67. 7 0
      backend/src/main/java/com/example/hrlab/payroll/PayrollItemRequest.java
  68. 9 0
      backend/src/main/java/com/example/hrlab/payroll/PayrollPeriodRequest.java
  69. 6 0
      backend/src/main/java/com/example/hrlab/payroll/PayrollRunRequest.java
  70. 407 0
      backend/src/main/java/com/example/hrlab/payroll/PayrollService.java
  71. 129 0
      backend/src/main/java/com/example/hrlab/payroll/PayrollVariableController.java
  72. 13 0
      backend/src/main/java/com/example/hrlab/payroll/PayrollVariableRequest.java
  73. 4 0
      backend/src/main/java/com/example/hrlab/payroll/PayslipBatchPublishRequest.java
  74. 9 0
      backend/src/main/java/com/example/hrlab/performance/AppraisalCycleRequest.java
  75. 9 0
      backend/src/main/java/com/example/hrlab/performance/AppraisalRecordRequest.java
  76. 37 0
      backend/src/main/java/com/example/hrlab/performance/AppraisalValidator.java
  77. 181 0
      backend/src/main/java/com/example/hrlab/performance/PerformanceController.java
  78. 9 0
      backend/src/main/java/com/example/hrlab/recruitment/CandidateRequest.java
  79. 9 0
      backend/src/main/java/com/example/hrlab/recruitment/InterviewRequest.java
  80. 7 0
      backend/src/main/java/com/example/hrlab/recruitment/InterviewResultRequest.java
  81. 11 0
      backend/src/main/java/com/example/hrlab/recruitment/JobRequisitionRequest.java
  82. 6 0
      backend/src/main/java/com/example/hrlab/recruitment/OfferDecisionRequest.java
  83. 293 0
      backend/src/main/java/com/example/hrlab/recruitment/RecruitmentController.java
  84. 131 0
      backend/src/main/java/com/example/hrlab/reporting/ExportService.java
  85. 138 0
      backend/src/main/java/com/example/hrlab/reporting/ReportController.java
  86. 96 0
      backend/src/main/java/com/example/hrlab/reporting/SimpleXlsxWriter.java
  87. 10 0
      backend/src/main/java/com/example/hrlab/security/AuthUser.java
  88. 47 0
      backend/src/main/java/com/example/hrlab/security/AuthUserService.java
  89. 60 0
      backend/src/main/java/com/example/hrlab/security/DataScopeService.java
  90. 39 0
      backend/src/main/java/com/example/hrlab/security/HmacService.java
  91. 36 0
      backend/src/main/java/com/example/hrlab/security/IdempotencyService.java
  92. 48 0
      backend/src/main/java/com/example/hrlab/security/JwtAuthenticationFilter.java
  93. 47 0
      backend/src/main/java/com/example/hrlab/security/JwtService.java
  94. 186 0
      backend/src/main/java/com/example/hrlab/security/SecurityAdminController.java
  95. 64 0
      backend/src/main/java/com/example/hrlab/security/SecurityCatalogPolicy.java
  96. 66 0
      backend/src/main/java/com/example/hrlab/security/SecurityConfig.java
  97. 22 0
      backend/src/main/java/com/example/hrlab/security/SecurityUtils.java
  98. 77 0
      backend/src/main/java/com/example/hrlab/security/SensitiveDataService.java
  99. 41 0
      backend/src/main/java/com/example/hrlab/system/SystemController.java
  100. 11 0
      backend/src/main/resources/application-test.yml

+ 66 - 0
.github/workflows/ci.yml

@@ -0,0 +1,66 @@
+name: ci
+
+on:
+  push:
+  pull_request:
+  workflow_dispatch:
+    inputs:
+      run_e2e:
+        description: "运行浏览器冒烟"
+        required: false
+        default: "false"
+
+jobs:
+  build-test:
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v4
+
+      - uses: actions/setup-java@v4
+        with:
+          distribution: temurin
+          java-version: "21"
+          cache: maven
+
+      - name: Backend unit tests
+        working-directory: backend
+        run: |
+          chmod +x mvnw
+          ./mvnw -q test
+
+      - name: Backend integration tests
+        working-directory: backend
+        run: ./mvnw -q failsafe:integration-test failsafe:verify
+
+      - uses: actions/setup-node@v4
+        with:
+          node-version: "22"
+          cache: npm
+          cache-dependency-path: frontend/package-lock.json
+
+      - name: Frontend build
+        working-directory: frontend
+        run: |
+          npm ci
+          npm run build
+
+      - name: OpenAPI validation
+        working-directory: tests/contract
+        run: |
+          npm ci
+          npm run validate
+
+      - name: Docker image build
+        run: |
+          docker build -f deploy/backend.Dockerfile -t organization-people-platform-backend .
+          docker build -f deploy/frontend.Dockerfile -t organization-people-platform-frontend .
+
+      - name: E2E smoke
+        if: github.event_name == 'workflow_dispatch' && github.event.inputs.run_e2e == 'true'
+        run: |
+          chmod +x scripts/*.sh
+          scripts/dev-up.sh
+          cd tests/e2e
+          npm ci
+          npx playwright install --with-deps chromium
+          npm test

+ 14 - 0
.gitignore

@@ -0,0 +1,14 @@
+node_modules/
+dist/
+target/
+storage/
+.idea/
+.vscode/
+*.log
+*.tmp
+*.class
+backend/target/
+frontend/dist/
+frontend/node_modules/
+tests/contract/node_modules/
+tests/e2e/node_modules/

+ 489 - 0
AGENTS.md

@@ -0,0 +1,489 @@
+# 用于CNAS软件测评模拟检测的企业人力资源管理系统样本程序方案与AI生成Prompt
+
+## 执行摘要
+
+本报告给出的结论是:**建议生成一套“单企业、单租户、前后端分离、模块化单体”的企业人力资源管理系统样本程序**,以完整覆盖员工档案、招聘、入职/离职、考勤、薪酬、绩效、权限与审计、报表与导出、接口与集成九大模块;技术上采用**真实企业常见栈**,但控制为**一个主应用 + 一个异步任务进程 + 标准中间件**,避免微服务化带来的部署、联调和回归负担。这样做的依据是:CNAS 当前针对检测实验室认可仍以 CNAS-CL01 及其领域应用文件为核心,常规检测实验室申请需要满足法律地位、管理体系运行、内审/管评、能力验证和资源配置等要求;软件检测领域应用说明则进一步强调测试输入项、测试环境、测试工具、版本控制、技术记录和结果有效性;上传的真实测试报告案例又显示,真实报告并不只看“功能能不能用”,还会重点看账号锁定、会话时效、越权访问、界面反馈、删除完整性、回滚一致性、用户文档和符合性评价。citeturn2view1turn2view2turn29view1turn29view2turn29view3turn29view5turn10search1turn6search5 fileciteturn0file0 fileciteturn0file3 fileciteturn0file4
+
+从上传的 GB/T 25000.10-2016 与 GB/T 25000.51-2016 可见,面向软件测评的样本程序不能只覆盖功能适合性,还应覆盖性能效率、兼容性、易用性、可靠性、信息安全性、可维护性和可移植性,以及用户文档、测试文档和符合性评价等内容。因此,本报告推荐的样本程序不是“玩具 CRUD 系统”,而是一个**有完整业务流、有权限边界、有长任务、有审计链、有自动化测试、有 Docker 化部署、有 OpenAPI 文档、有可重复测试数据和故障注入能力**的“组织人事管理平台”。同时,为满足您的约束,**程序本身不得出现 CNAS 字样,不得包含真实个人、企业、政府、社会团体名称或信息,所有示例数据都应为合成数据。** fileciteturn0file1 fileciteturn0file2
+
+| 决策项 | 建议 |
+|---|---|
+| 样本程序定位 | 单主题、单程序、企业级 HR 管理系统样本 |
+| 推荐架构 | 模块化单体 + 独立异步 Worker |
+| 默认技术栈 | Java 21 + Spring Boot 3.5.x + Vue 3 + PostgreSQL + Redis + RabbitMQ |
+| 必须覆盖模块 | 员工档案、招聘、入职/离职、考勤、薪酬、绩效、权限与审计、报表与导出、接口与集成 |
+| 核心测试能力 | 功能、边界/异常、安全、接口兼容、性能、可靠性、回归 |
+| 可测性增强 | Demo 数据生成、固定时钟、日志级别控制、故障注入、OpenAPI、自动化测试、脚本化部署 |
+| 简化边界 | 不做多租户、不做真实税务社保法规引擎、不接真实短信/邮件/政府接口 |
+| 强约束 | 禁止 CNAS 字样;禁止真实组织/个人信息;禁止“为了检测故意做得很水” |
+
+## 研究依据与设计原则
+
+截至 2026 年 4 月的 CNAS 实验室认可规范文件清单中,**CNAS-CL01-A019:2018《检测和校准实验室能力认可准则在软件检测领域的应用说明》**仍列为有效文件;CNAS 官网对常规检测实验室申请材料的说明则明确要求法律地位、管理体系运行、内审与管理评审、能力验证计划与结果、资源条件等;CNAS-RL02:2023 又要求实验室基于风险评估制定能力验证工作计划,并在没有合适能力验证时强化其他质量保证手段。对于您要做的“内部模拟检测样本”,这意味着样本程序不应只是“给工程师点点页面”的演示软件,而要能承载**完整测试输入、版本信息、运行环境、技术记录、结果监控、自动化回归和异常复现**。citeturn6search5turn2view2turn29view5turn29view6
+
+CNAS-CL01-A019 对软件检测特别关键的几项约束是:测试合同与项目评审中应明确测试输入项、测试结束条件、测试风险和交付方式;接收被测软件时要记录程序、文档、数据和版本号,并进行唯一性标识;技术记录应至少覆盖测试输入项记录、测试技术文档、测试环境记录、测试执行记录和技术评审记录;实验室还应有保证结果有效性的质量监控方案,可采用样例软件或标准方法开展内部测试,也可采用实验室间比对或能力验证。CNAS-CL01-G001:2024 进一步要求信息管理系统满足**审核路径、数据安全与完整性**等要求。正是这些条款,直接决定了样本程序必须自带版本信息、审计链、环境清单、测试数据、回归套件和已知可复现场景。citeturn29view4turn29view2turn29view3turn29view0
+
+您上传的真实案例也非常有参考价值。上传的可靠性测试报告显示,单一对象就执行了大量有效用例,并明确检查了**超范围输入、账号锁定、会话时效、界面即时反馈、不同角色会话独立、越权访问、删除完整性、敏感操作确认和事务回滚**等点;上传的软件产品登记测试报告则体现出**用户文档 + 功能符合性**的评价框架;上传的委托测试申请表要求提供**产品简介、软件/硬件环境、访问方式、账号和待测功能清单**。因此,如果样本程序缺少文档、缺少账号矩阵、缺少环境脚本、缺少日志与审计、缺少异常路径,工程师在内部模拟检测中就无法真实展示公司的检测深度。fileciteturn0file0 fileciteturn0file3 fileciteturn0file4
+
+从这些依据出发,我建议遵循三条设计原则:**完整但有边界、真实但不过度复杂、可测且可重复**。这里的“完整但有边界”,指功能要覆盖真实 HR 业务闭环,但不引入真实税务或社保法规;“真实但不过度复杂”,指要采用生产常见技术栈、权限控制、异步任务、缓存、审计和自动化测试,但避免为样本而拆成多微服务;“可测且可重复”,指要把 Demo 数据、固定时钟、故障点、日志关联 ID、OpenAPI、回归测试和部署脚本全部内置。上述判断是基于官方规范和真实报告案例所作的设计推断。citeturn29view1turn29view2turn29view3turn29view5turn29view0 fileciteturn0file0
+
+| 依据层面 | 关键要求或观察 | 对样本程序的直接影响 |
+|---|---|---|
+| 认可准则层 | 申请认可不仅看代码,还看体系、记录、能力验证、资源、风险控制 | 样本必须能形成完整“可检测对象”,而非单纯源码 |
+| 软件检测应用层 | 关注测试输入、测试文档、测试环境、执行记录、版本控制、结果有效性 | 程序要附带环境脚本、版本标识、日志审计、测试数据、回归套件 |
+| 信息管理层 | 信息管理系统应有审核路径、数据安全、完整性 | 程序要有审计链、操作留痕、日志关联、不可抵赖的关键记录 |
+| 软件质量模型层 | 质量覆盖功能、性能、兼容、易用、可靠、安全、维护、移植 | 方案必须同时设计功能点与质量属性点 |
+| 真实案例层 | 真实报告会测越权、锁定、时效、回滚、删除完整性、用户文档 | 样本必须具备“异常路径”和“文档化”能力 |
+| 送检资料层 | 实际委托资料要求简介、环境、访问地址、账号、待测功能清单 | 交付物必须结构化、可交接、可重复启动 |
+
+## 业务范围与功能清单
+
+主流企业级 HCM/HRIS 产品普遍把员工主数据、招聘、缺勤/休假、考勤、绩效、工资、工作流和分析报表放在同一套系统中。Odoo 的 HR 文档目录明确列出了员工、出勤、休假、招聘、评估和工资;Oracle HCM 将 Human Resources、Talent Management、Workforce Management、Payroll 归为同一云解决方案;Workday 招聘模块则覆盖 requisition、candidate pipeline、面试与 offer 流程。结合 GB/T 25000.10/51 对功能与质量维度的要求,本样本程序覆盖九大模块是合理且必要的。citeturn24view1turn24view2turn24view3turn24view4 fileciteturn0file1 fileciteturn0file2
+
+### 功能清单
+
+| 模块 | 必做功能 | 关键用例 | 边界/异常重点 | 检测价值 |
+|---|---|---|---|---|
+| 员工档案 | 组织树、部门、岗位、员工主档、合同信息、联系方式、在职状态、附件元数据、变更历史 | 新增员工、转岗转部门、状态变更、档案检索、批量导入 | 员工编号重复、未来生效时间、转岗前后权限变化、附件类型/大小限制、敏感字段脱敏 | 功能完整性、字段校验、数据一致性、权限联动 |
+| 招聘 | 职位需求、候选人、简历信息、面试安排、面试反馈、Offer、录用转入职 | 建立岗位需求、录入候选人、安排面试、发放 Offer、转入入职流程 | 候选人重复、岗位已关闭仍允许投递、面试时间冲突、同一候选人重复转化 | 状态流转、边界校验、流程一致性 |
+| 入职/离职 | 入职清单模板、账号开通申请、试用期标记、离职申请、交接项、账号停用 | Offer 接受后生成入职单、入职完成激活员工、离职后自动停用账号 | 必填入职项未完成、离职日期与薪酬期间冲突、存在未完成交接项时阻断关闭 | 业务闭环、流程阻断、与权限/薪酬联动 |
+| 考勤 | 班次、签到签退、考勤异常、请假、加班、月度汇总 | 正常打卡、跨天班次、缺卡补录、请假审批、加班审批 | 请假时间重叠、跨天工时、缺少签退、节假日/休息日规则、人工修正留痕 | 时间边界、规则计算、追溯审计 |
+| 薪酬 | 薪资项目、薪酬期间、公式计算、考勤扣减、绩效奖金、审核、锁定、工资单发布 | 月度核算、复核、审批、锁定、发布工资单 | 期间锁定后变更、防重复核算、公式异常回滚、离职当月折算、敏感信息掩码 | 金额准确性、事务一致性、不可变更性 |
+| 绩效 | 绩效周期、模板、目标、员工自评、经理评价、最终评级、分布分析 | 发布绩效周期、提交自评、经理评分、结案归档 | 权重不等于 100、超期提交、跨部门经理越权评分、结案后编辑 | 复杂表单、审批链、对象级授权 |
+| 权限与审计 | 本地账号登录、JWT/刷新令牌、角色、部门数据范围、敏感操作二次确认、审计链 | 登录、登出、重置密码、越权阻断、审计查询 | 暴力登录、令牌过期、同一用户多会话、行级越权、审计缺失 | 安全、合规、可追溯性 |
+| 报表与导出 | 人员结构报表、招聘漏斗、考勤汇总、薪酬汇总、绩效分布、异步导出 | 条件查询、CSV/XLSX 导出、下载与过期 | 大范围查询转异步、导出文件过期、无权限导出、导出失败重试 | 性能、权限、长任务处理 |
+| 接口与集成 | OpenAPI/Swagger、员工查询 API、批量导入 API、Webhook 事件、签名校验、幂等键 | 外部查询员工状态、导入考勤、发出入职/离职/工资发布事件 | 无效签名、重复回调、接口版本不兼容、部分导入成功与错误行导出 | 接口兼容性、幂等性、异常恢复 |
+
+### 角色与权限范围
+
+| 角色 | 主要能力 | 数据范围 |
+|---|---|---|
+| SYSTEM_ADMIN | 系统参数、角色权限、审计查询、健康检查、任务监控 | 全局 |
+| HR_ADMIN | 员工档案、组织、入离职、报表 | 全局或人事指定范围 |
+| RECRUITER | 职位需求、候选人、面试、Offer | 招聘模块全局 |
+| DEPT_MANAGER | 本部门员工查看、请假/加班审批、绩效评分 | 本部门及下级部门 |
+| PAYROLL_ADMIN | 薪酬配置、核算、复核、发布工资单 | 薪酬模块全局 |
+| EMPLOYEE_SELF | 查看/更新本人部分信息、打卡、请假、自评 | 仅本人 |
+| AUDITOR | 只读查看审计、导出记录、关键日志报表 | 全局只读 |
+| INTEGRATION_CLIENT | 调用受限 API、接收/发送 Webhook | 指定接口范围 |
+
+需要特别强调的是,上传的可靠性案例并非只测“happy path”,而是明确覆盖了账号锁定、会话超时、越权访问、弹窗反馈、关联数据删除完整性和事务回滚等非功能/异常场景。因此,上表九大模块都不能只停留在 CRUD,而必须带有实际业务状态机、权限边界和失败路径,才能真正体现贵司工程师的检测能力。fileciteturn0file0
+
+## 技术架构与数据模型
+
+我推荐的默认实现形态是**模块化单体**。这是一个有意识的折中:一方面,Spring Boot 可以快速构建可独立运行、具备生产就绪特性的应用;Spring Security 同时提供认证、授权、常见攻击防护、方法级授权、OAuth2/JWT 支持,并可扩展 LDAP;Spring Data JPA 能减少数据访问层样板代码并提供分页、查询与审计能力。另一方面,相比微服务,模块化单体更适合内部模拟检测:部署更轻、环境更稳、排障更快、回归更可控,但又不牺牲 REST API、缓存、消息队列、审计、健康检查和自动化测试等真实企业要素。这个推荐是基于上述官方能力和检测目标作出的设计推断。citeturn25view0turn25view1turn25view2turn25view3turn25view4turn18search3
+
+在基础设施层面,PostgreSQL 的主键、唯一约束和外键能保证关系完整性,`jsonb` 适合承载少量可扩展字段;Redis 的 TTL/EXPIRE 机制适合做登录失败计数、导出文件缓存和短期聚合缓存;RabbitMQ 的 Work Queue 非常适合把批量导入、导出、薪酬核算等耗时任务从同步请求中剥离出来,同时通过 consumer ack 和 publisher confirm 提升消息安全性;OpenAPI 既能让人和工具理解 API,也能被文档、代码生成和测试工具消费;Spring Boot Actuator、Micrometer 与 OpenTelemetry 能提供健康检查、指标、日志和链路追踪;Docker Compose 适合把前端、后端、数据库和中间件统一拉起;GitHub Actions 可以自动化 CI/CD;Testcontainers、Playwright、k6 和 ZAP 则分别覆盖集成测试、浏览器 E2E、压测和 API 安全扫描。citeturn27view5turn27view6turn27view2turn27view3turn27view4turn27view7turn25view5turn27view8turn27view1turn26view4turn26view3turn26view2turn26view0turn26view1turn27view0turn26view5
+
+### 技术栈与架构推荐表
+
+| 层面 | 可选方案 | 默认推荐 | 推荐理由 |
+|---|---|---|---|
+| 后端 | Java + Spring Boot;C# + ASP.NET Core;TypeScript + NestJS | **Java 21 + Spring Boot 3.5.x** | 生态完整,适合权限、事务、消息、审计、测试和文档化 |
+| 前端 | Vue 3;React | **Vue 3 + TypeScript + Vite + Element Plus** | 更适合中后台表单/表格/审批流界面 |
+| 数据库 | PostgreSQL;MySQL | **PostgreSQL 16+** | 关系完整性强,`jsonb` 适合扩展字段 |
+| 认证授权 | Session;JWT;OAuth2/OIDC;LDAP | **本地账号 + JWT + Refresh Token,预留 OIDC/LDAP 适配接口** | 默认简单可控,同时可测试 SSO 兼容接口 |
+| 缓存 | Redis;Caffeine | **Redis** | 可做登录锁定、短期缓存、任务状态缓存 |
+| 消息/任务队列 | RabbitMQ;Kafka | **RabbitMQ** | 更适合样本中的异步任务和失败重试 |
+| 日志与审计 | 应用日志;数据库审计表;OTel | **业务审计表 + 结构化日志 + OTel 就绪** | 同时满足安全取证与性能观测 |
+| API 文档 | 手写文档;OpenAPI | **OpenAPI 3.1 + Swagger UI** | 便于人工检测和自动化扫描 |
+| 数据库变更 | 手工 SQL;Liquibase;Flyway | **Flyway** | 版本化迁移简单,便于复现与回归 |
+| 自动化测试 | 单元;集成;E2E;性能;安全 | **JUnit 5 + Testcontainers + Playwright + k6 + ZAP** | 覆盖最完整,且都可脚本化执行 |
+| 部署 | 直接运行;Docker;K8s | **Docker Compose** | 对样本程序最省运维成本且足够真实 |
+| CI/CD | GitHub Actions;GitLab CI | **GitHub Actions 示例,保留迁移空间** | 文档充分且便于 AI 生成 |
+
+### 系统组件关系图
+
+下图展示了推荐的“前后端分离 + 模块化单体 + 异步 Worker”结构。这样既能保留真实企业系统中的 API、缓存、消息队列、数据存储、审计和观测能力,又能把内部模拟检测的部署复杂度控制在一个 Docker Compose 之内。citeturn25view0turn25view5turn26view3turn27view3turn27view4
+
+```mermaid
+flowchart LR
+    subgraph Client["客户端"]
+        UI["Vue Web UI"]
+    end
+
+    subgraph App["HR 样本程序"]
+        API["REST API / Auth Gateway"]
+        EMP["员工与组织模块"]
+        REC["招聘模块"]
+        ONOFF["入职/离职模块"]
+        ATT["考勤模块"]
+        PAY["薪酬模块"]
+        PERF["绩效模块"]
+        REP["报表导出模块"]
+        AUD["权限与审计模块"]
+        WORKER["异步任务 Worker"]
+    end
+
+    PG[("PostgreSQL")]
+    REDIS[("Redis")]
+    MQ[("RabbitMQ")]
+    FILES[("本地文件存储")]
+    OBS["Actuator / Metrics / Traces / Logs"]
+    WEBHOOK["Webhook Sandbox"]
+
+    UI --> API
+    API --> EMP
+    API --> REC
+    API --> ONOFF
+    API --> ATT
+    API --> PAY
+    API --> PERF
+    API --> REP
+    API --> AUD
+
+    API --> PG
+    API --> REDIS
+    API --> MQ
+    API --> FILES
+    API --> OBS
+
+    WORKER --> MQ
+    WORKER --> PG
+    WORKER --> REDIS
+    WORKER --> FILES
+    WORKER --> WEBHOOK
+    WORKER --> OBS
+```
+
+### 数据流图
+
+在数据流上,建议把**批量导入、导出、薪酬核算、Webhook 重试**全部作为异步任务处理。这样既符合长任务处理的工程实践,也更适合工程师做性能、可靠性和异常恢复检测。citeturn27view3turn27view4turn27view2turn27view8
+
+```mermaid
+sequenceDiagram
+    actor HR as HR专员
+    participant FE as 前端
+    participant API as API服务
+    participant DB as PostgreSQL
+    participant Cache as Redis
+    participant MQ as RabbitMQ
+    participant Worker as Worker
+    participant File as 文件存储
+
+    HR->>FE: 发起批量导入/导出/薪酬核算
+    FE->>API: REST请求 + JWT
+    API->>DB: 保存请求与业务状态
+    API->>Cache: 写入短期状态/失败计数
+    API->>MQ: 投递异步任务
+    API-->>FE: 返回任务ID
+
+    MQ->>Worker: 获取任务
+    Worker->>DB: 读取业务数据
+    Worker->>File: 生成CSV/XLSX
+    Worker->>DB: 更新任务状态与结果
+    FE->>API: 轮询任务状态
+    API-->>FE: 返回成功/失败/下载地址
+```
+
+### 示例 ER 图
+
+示例 ER 图不是要把所有表一次性画满,而是用于明确“哪些实体必须存在,哪些关系必须可测”。对 HR 样本来说,下面这些是最有检测价值的主实体。citeturn27view5turn27view6
+
+```mermaid
+erDiagram
+    DEPARTMENT ||--o{ EMPLOYEE : contains
+    POSITION ||--o{ EMPLOYEE : assigns
+    EMPLOYEE ||--|| USER_ACCOUNT : owns
+    USER_ACCOUNT ||--o{ USER_ROLE : maps
+    ROLE ||--o{ USER_ROLE : grants
+    ROLE ||--o{ ROLE_PERMISSION : includes
+    PERMISSION ||--o{ ROLE_PERMISSION : included_by
+
+    JOB_REQUISITION ||--o{ CANDIDATE : receives
+    CANDIDATE ||--o{ INTERVIEW : has
+    CANDIDATE ||--o| ONBOARDING_CASE : converts_to
+
+    EMPLOYEE ||--o{ ATTENDANCE_RECORD : marks
+    EMPLOYEE ||--o{ LEAVE_REQUEST : applies
+    EMPLOYEE ||--o{ OFFBOARDING_CASE : exits
+
+    PAYROLL_PERIOD ||--o{ PAYROLL_RUN : contains
+    PAYROLL_RUN ||--o{ PAYSLIP : generates
+    EMPLOYEE ||--o{ PAYSLIP : receives
+
+    APPRAISAL_CYCLE ||--o{ APPRAISAL_RECORD : includes
+    EMPLOYEE ||--o{ APPRAISAL_RECORD : evaluated
+
+    USER_ACCOUNT ||--o{ AUDIT_LOG : acts
+    USER_ACCOUNT ||--o{ EXPORT_JOB : creates
+    USER_ACCOUNT ||--o{ IMPORT_JOB : creates
+    EMPLOYEE ||--o{ INTEGRATION_EVENT : triggers
+```
+
+### 关键表结构示例
+
+| 表名 | 核心字段示例 | 设计说明 |
+|---|---|---|
+| departments | id, parent_id, dept_code, dept_name, status, sort_no | 组织树、数据范围控制基础 |
+| positions | id, position_code, position_name, level_code, status | 员工与招聘共用岗位维度 |
+| employees | id, employee_no, department_id, position_id, display_name, employment_status, hire_date, leave_date, mobile_masked, id_no_cipher, bank_card_cipher, version | 主档核心表,带乐观锁 |
+| user_accounts | id, employee_id, username, password_hash, status, failed_attempts, locked_until, last_login_at | 登录、锁定、审计关联 |
+| login_sessions | id, user_account_id, refresh_token_hash, device_name, issued_at, expires_at, revoked_at | 多会话、注销、令牌撤销 |
+| job_requisitions | id, req_no, department_id, position_id, planned_headcount, status, opened_at, closed_at | 招聘需求 |
+| candidates | id, requisition_id, candidate_code, display_name, email, phone, source, status, ext_jsonb | 候选人与招聘流程 |
+| onboarding_cases | id, candidate_id, target_employee_id, status, checklist_jsonb, expected_join_date | 招聘转入职桥接表 |
+| attendance_records | id, employee_id, work_date, shift_code, clock_in_at, clock_out_at, late_minutes, early_leave_minutes, anomaly_status | 考勤主表 |
+| leave_requests | id, employee_id, leave_type, start_at, end_at, duration_hours, status, approver_id | 请假审批 |
+| payroll_periods | id, period_key, start_date, end_date, status, locked_at | 薪酬周期 |
+| payroll_runs | id, payroll_period_id, run_no, formula_version, status, started_at, finished_at, error_message | 核算批次 |
+| payslips | id, payroll_run_id, employee_id, base_salary, allowance_total, deduction_total, performance_bonus, net_amount, published_at | 工资单 |
+| appraisal_cycles | id, cycle_name, start_date, end_date, status, template_jsonb | 绩效周期 |
+| appraisal_records | id, cycle_id, employee_id, manager_id, self_score, manager_score, final_score, final_grade, status | 单人绩效结果 |
+| audit_logs | id, actor_user_id, action, entity_type, entity_id, before_json, after_json, ip_addr, user_agent, correlation_id, prev_hash, record_hash, occurred_at | 审计链,建议追加 hash 链 |
+| export_jobs | id, job_type, criteria_json, status, file_path, file_expire_at, created_by, finished_at | 异步导出 |
+| import_jobs | id, job_type, source_file, preview_result_json, status, success_count, fail_count, error_file_path, created_by | 异步导入 |
+| integration_events | id, event_type, business_key, payload_json, signature, status, retry_count, next_retry_at | Webhook 事件与重试 |
+
+### 示例 API
+
+OpenAPI 的价值在于:它既能驱动 Swagger UI,也能被代码生成工具、契约测试工具和 ZAP API Scan 直接消费。因此,样本程序最好把 OpenAPI 文档当成“一等交付物”,而不是后补附件。citeturn27view7turn26view5
+
+| 接口 | 方法 | 说明 | 关键检测点 |
+|---|---|---|---|
+| /api/v1/auth/login | POST | 本地登录获取 access token 与 refresh token | 锁定策略、错误提示、审计记录 |
+| /api/v1/employees | POST | 新增员工主档 | 必填项、编号唯一性、敏感字段处理 |
+| /api/v1/employees/{id} | GET | 查询员工详情 | 对象级授权、字段脱敏 |
+| /api/v1/candidates | POST | 新增候选人 | 重复检测、字段校验 |
+| /api/v1/onboarding-cases/{id}/complete | POST | 完成入职流程并激活员工 | 状态流转、联动账号创建 |
+| /api/v1/attendance/import-jobs | POST | 提交考勤批量导入任务 | 文件校验、异步任务、错误行导出 |
+| /api/v1/payroll/periods/{periodKey}/runs | POST | 发起薪酬核算批次 | 长任务、回滚、一致性 |
+| /api/v1/payslips/{id}/publish | POST | 发布工资单 | 角色权限、敏感操作确认 |
+| /api/v1/reports/headcount/export | POST | 组织人数统计异步导出 | 大数据量导出、权限控制 |
+| /api/v1/integration/webhooks/employee-status | POST | 外发员工状态变更事件 | 签名、幂等、失败重试 |
+| /api/v1/system/about | GET | 管理员查看构建信息、迁移版本、profile | 版本识别、环境复现 |
+| /actuator/health | GET | 系统健康检查 | 部署后冒烟、依赖状态识别 |
+
+示例登录请求:
+
+```json
+{
+  "username": "hr_admin",
+  "password": "ChangeMe123!"
+}
+```
+
+示例登录成功响应:
+
+```json
+{
+  "code": "OK",
+  "message": "success",
+  "correlationId": "c4f1e302-3d49-4d2d-8a0f-f8c0d52a91b7",
+  "data": {
+    "accessToken": "<jwt>",
+    "refreshToken": "<opaque-token>",
+    "expiresIn": 3600,
+    "user": {
+      "userId": "U0001",
+      "displayName": "员工0001",
+      "roles": ["HR_ADMIN"],
+      "dataScopes": ["ALL"]
+    }
+  }
+}
+```
+
+示例发起薪酬核算请求:
+
+```json
+{
+  "recalculate": false,
+  "includeDepartments": ["D-HR-01", "D-DEV-01"],
+  "formulaVersion": "v1.0.0"
+}
+```
+
+示例发起薪酬核算响应:
+
+```json
+{
+  "code": "ACCEPTED",
+  "message": "payroll run submitted",
+  "correlationId": "0f03cf1e-c0d2-4a9d-9a8e-5d0ed09b1b0e",
+  "data": {
+    "jobId": "JOB-PAY-202601-0001",
+    "status": "QUEUED"
+  }
+}
+```
+
+## 安全性能与可测性设计
+
+### 安全与合规检测点
+
+上传的真实可靠性案例把账号锁定、会话超时、界面反馈、角色会话独立、越权访问、删除完整性、敏感操作确认和事务回滚都当成检查点;OWASP 则把访问控制失效、认证与会话失效、密码学失效、输入校验不足、日志与监控不足、API 对象级授权失效列为高风险区域。因此,这套样本程序应当重点做到:**权限默认拒绝、对象级鉴权、细粒度方法授权、令牌与会话治理、敏感数据保护、输入与文件校验、审计链完整、危险操作二次确认**。fileciteturn0file0 citeturn28view0turn28view1turn28view2turn28view3turn28view4turn28view5turn28view6turn28view7
+
+我不建议在样本程序中预埋真正可利用的 SQL 注入或越权后门。更好的做法是:在 `lab` profile 下提供**故障注入**和**弱配置模拟器**,让工程师验证“系统能防住、能报警、能记录、能复现”,而不是把样本做成靶场。这一判断来自 CNAS 对方法、记录和结果有效性的关注,以及真实报告更偏向“确认控制措施是否有效”而不是“证明漏洞一定存在”。citeturn29view3turn29view5turn29view0turn28view4
+
+| 检测点 | 必须实现 | 推荐默认值/机制 | 典型检测场景 |
+|---|---|---|---|
+| 身份认证 | 本地账号登录、密码哈希、失败锁定、密码修改后令牌失效 | 失败阈值可配置;密码使用 Argon2id 或 BCrypt;登录成功重置失败计数 | 暴力登录、弱口令、锁定后解锁 |
+| 会话与令牌 | JWT Access + Refresh,支持注销与令牌失效 | access token 60 分钟;refresh token 7 天;`login_sessions` 记录会话 | 令牌过期、注销后重放、多设备会话 |
+| 功能级授权 | 路由级 + 方法级双重控制 | Spring Security request-level + method-level,默认拒绝 | 非薪酬岗位调用发布工资单接口 |
+| 对象级授权 | 按部门 / 本人 / 审批链校验对象访问 | Department Data Scope + Owner Check + Approver Check | 改 URL 访问他人员工档案或工资单 |
+| 输入校验 | 语法 + 语义双层校验,文件上传验证 | DTO 校验 + 业务校验 + 文件类型/大小白名单 | XSS 字符串、非法日期、负数工资、异常文件 |
+| 敏感数据保护 | 密码不可逆;核心敏感字段加密存储、界面脱敏 | `id_no_cipher`、`bank_card_cipher`;只显示掩码 | 数据库泄露演练、导出脱敏验证 |
+| 审计链 | 关键操作记录 before/after、操作者、IP、UA、关联 ID | 追加式 `audit_logs`,可加入 `prev_hash`/`record_hash` | 删除候选人、发布工资单、重置密码 |
+| 危险操作确认 | 不是所有按钮都直接生效 | 对工资发布、批量导入确认、角色授权变更进行二次确认 | 防误操作、操作可追溯 |
+| 删除策略 | 核心业务多采用归档/失效,不做物理删除;可删资源必须级联受控 | 员工/工资/绩效不物理删除;草稿候选人可删 | 删除完整性、关联残留检查 |
+| 接口安全 | 签名、幂等键、速率限制 | `X-Signature` + `Idempotency-Key` + Redis 计数器 | 重放攻击、重复回调、接口刷用 |
+
+### 性能与可靠性检测点
+
+RabbitMQ 的 Work Queue 设计目标就是把资源密集任务延后执行并交给后台 worker;consumer ack 和 publisher confirm 用于数据安全;Redis TTL 天然适合做速率限制和短期缓存;PostgreSQL 既支持事务回滚,也支持 SQL dump、文件级备份和持续归档等备份方式;Spring Boot/Actuator 与 OpenTelemetry 则可提供健康检查、日志、指标和追踪。基于这些能力,我建议把性能和可靠性的重点放在**并发登录、检索与报表、批量导入导出、薪酬长任务、队列失败恢复、备份恢复和健康检查**上。citeturn27view3turn27view4turn27view2turn22search7turn22search4turn22search5turn27view8turn27view1turn26view4
+
+| 场景 | 建议数据规模 | 处理方式 | 建议基准 | 检测重点 |
+|---|---|---|---|---|
+| 并发登录 | 200 并发用户 | 同步鉴权 + Redis 失败计数 | 成功率 ≥ 99%,p95 ≤ 1.5s | 鉴权性能、锁定策略、日志完整性 |
+| 员工检索/列表分页 | 2,000 员工、20+ 条件组合查询 | 同步分页查询 + 关键索引 + 缓存组织树 | p95 ≤ 800ms | 查询性能、排序/过滤正确性 |
+| 考勤批量导入 | 50,000 行 CSV | 异步任务 + 预校验 + 错误行导出 | 提交请求 ≤ 2s,任务完成 ≤ 10 分钟 | 长任务稳定性、部分失败处理 |
+| 报表导出 | 10,000 行以上 | 异步导出 CSV/XLSX | 提交请求 ≤ 2s,结果生成 ≤ 3 分钟 | 大数据量导出、文件过期与权限 |
+| 薪酬核算 | 2,000 员工,5~10 薪资项 | 异步批次 + 事务提交 | 批次完成 ≤ 5 分钟 | 金额准确性、失败回滚、状态机 |
+| Webhook 重试 | 100 个事件,10% 人工模拟失败 | 重试队列 + 指数退避 + 死信队列 | 最终可见失败原因,避免无声丢失 | 幂等、重试、告警、事件追踪 |
+| 容灾恢复 | 一套完整样本库 | `pg_dump/pg_restore` 脚本 + Docker restore | 可在预定时限内完成恢复并通过冒烟 | 备份可用性、恢复一致性 |
+| 健康检查 | 应用 + DB + Redis + MQ | `/actuator/health` + 自定义 about | 依赖异常能被准确暴露 | 可运维性、部署后验证 |
+
+### 可测性设计
+
+CNAS-CL01-A019 要求软件测试方法通常包括测试用例集、测试工具和相关程序;技术记录要覆盖测试输入项、环境、执行与评审;结果有效性要通过样例软件、内部测试或外部比对来监控。CNAS-CL01-G001 还要求信息管理系统具备审核路径、数据安全和完整性。对样本程序来说,这意味着要把“可测性”做成**程序特性**,而不是依赖工程师临时造数据、手工改配置、人工凑日志。citeturn29view1turn29view2turn29view3turn29view0turn29view6
+
+| 可测性设计项 | 具体做法 | 对检测的价值 |
+|---|---|---|
+| `lab` profile | 使用 `application-lab.yml`,仅在实验 profile 启用 Demo 功能 | 将样本模式与“准生产模式”隔离 |
+| 合成数据生成器 | 可按固定随机种子生成 2,000 员工、500 候选人、180 天考勤、12 个绩效周期 | 压测、边界测试、重复回归都可复现 |
+| 固定时钟 | 所有业务时间通过 `Clock` 注入,可在 lab 模式冻结到指定日期 | 便于测试会话超时、月末薪酬、绩效结案 |
+| 故障注入点 | 导出延迟、MQ nack、Webhook 5xx、工资公式异常、缓存关闭、外部签名失败 | 可重复演练性能、恢复、异常处理 |
+| 日志级别控制 | 支持模块级日志级别;所有响应返回 `correlationId` | 便于故障定位和证据留存 |
+| 自检端点 | `/actuator/health`、`/api/v1/system/about`、任务统计接口 | 便于部署后冒烟与版本确认 |
+| 任务可视化 | 导入/导出/薪酬批次有任务表、执行日志、错误明细 | 长任务检测可观察、可核验 |
+| 契约输出 | 自动生成 `openapi.yaml` 与 Swagger UI | 接口兼容与 ZAP API 扫描更容易 |
+| 自动化脚本 | 一键启动、造数、跑冒烟、备份、恢复、压测、安全扫描 | 内部模拟检测效率显著提高 |
+| 硬性禁区 | **不提供跳过鉴权的后门开关** | 避免样本质量下滑为“演示脚本” |
+
+示例 `lab` 配置建议如下:
+
+```yaml
+app:
+  lab:
+    enabled: true
+    fixed-clock: "2026-01-31T18:00:00+08:00"
+    seed:
+      enabled: true
+      random-seed: 20260517
+      employee-count: 2000
+      candidate-count: 500
+      attendance-days: 180
+    failpoint:
+      enabled: true
+      export-delay-ms: 0
+      payroll-extra-delay-ms: 0
+      webhook-force-5xx-rate: 0.0
+      mq-requeue-once: true
+logging:
+  level:
+    com.example.hrsample: INFO
+```
+
+## 测试用例与检测清单
+
+CNAS-RL02 要求机构依据风险评估制定能力验证和质量保证计划;A019 要求结果有效性监控;上传的真实可靠性案例又表明,在一个单项报告中也可能出现大量有效测试用例。因此,样本程序不宜只准备几条“演示流程”,而应至少准备一套**最小强覆盖集**。下面给出的用例集,适合作为贵司工程师内部模拟检测的“主清单”;在此基础上再扩展出更细的参数化和组合用例即可。citeturn29view5turn29view3turn29view6 fileciteturn0file0
+
+### 功能、边界与安全用例
+
+| 编号 | 类别 | 场景 | 步骤 | 预期结果 | 判定标准 | 优先级 |
+|---|---|---|---|---|---|---|
+| F-01 | 功能 | 登录、锁定与解锁 | 连续输错密码达到阈值,再输入正确密码;等待锁定期后再次登录 | 锁定期间拒绝登录;解锁后可正常登录;生成审计日志 | 返回码、提示文案、锁定时长、审计记录均符合 | P1 |
+| F-02 | 功能 | 员工新增与转岗 | 新增员工;查看档案;执行转岗转部门;查看历史 | 员工建立成功;历史记录完整;数据范围同步变化 | 员工详情、历史记录、权限变化均正确 | P1 |
+| F-03 | 功能 | 招聘转入职 | 新建需求、录候选人、安排面试、发 Offer、接受后转入职 | 状态机完整流转,自动生成入职单 | 每一步状态准确,不能越级跳转 | P1 |
+| F-04 | 功能 | 入职完成激活账号 | 完成所有必填入职项后激活账号 | 未完成必填项不得激活;完成后账号可登录 | 业务阻断正确,联动账号创建成功 | P1 |
+| F-05 | 功能 | 离职闭环 | 提交离职、完成交接、关闭账号、校验下期薪酬名单 | 离职后账号停用,后续期间不再进入工资单 | 状态、权限、薪酬联动正确 | P1 |
+| F-06 | 功能 | 考勤异常补录与审批 | 制造缺卡记录,提交补录申请,经理审批 | 补录后汇总更新并留痕 | 工时计算正确,异常清除正确 | P1 |
+| F-07 | 功能 | 薪酬核算、审批、锁定、发布 | 发起核算、复核、审批、锁定、发布工资单 | 锁定前可编辑,锁定后不可修改;员工能查看工资单 | 金额正确,状态机正确,不可变更性成立 | P1 |
+| F-08 | 功能 | 绩效自评与经理评价 | 发布绩效周期,员工自评,经理评分,管理员结案 | 权重校验正确;结案后不可改 | 分数、等级、权限、结案状态均正确 | P2 |
+| F-09 | 功能 | 报表异步导出 | 发起大范围导出,轮询状态,下载结果 | 请求快速返回任务 ID;完成后可下载 | 长任务、文件下载、权限控制正确 | P1 |
+| B-01 | 边界 | 员工编号重复 | 以已存在员工编号再次新增员工 | 新增失败,提示唯一性冲突 | DB 与 API 都阻止重复数据入库 | P1 |
+| B-02 | 边界 | 请假时间重叠 | 对同一员工提交时间交叉的两条请假单 | 第二条被拒绝 | 语义校验正确,无脏数据 | P1 |
+| B-03 | 边界 | 跨天班次缺少签退 | 创建 22:00-06:00 班次并故意缺签退 | 系统标记异常并允许补录流程 | 跨天工时、异常识别、补录入口正确 | P2 |
+| B-04 | 边界 | 公式异常回滚 | 人工配置非法公式后发起工资核算 | 批次失败但不产生半成品工资单;保留错误日志 | 事务回滚彻底,错误信息可见 | P1 |
+| S-01 | 安全 | 未认证访问敏感接口 | 不带令牌调用工资发布/审计查询接口 | 返回 401/403,不泄露内部信息 | 认证拦截正确,错误响应无敏感细节 | P1 |
+| S-02 | 安全 | 对象级越权 | 普通员工或经理修改 URL/ID 访问他人档案、工资单 | 拒绝访问并记录审计 | BOLA 被有效阻断 | P1 |
+| S-03 | 安全 | 功能级越权 | 非 PAYROLL_ADMIN 用户调用工资单发布接口 | 拒绝访问 | BFLA 被有效阻断 | P1 |
+| S-04 | 安全 | 恶意输入与文件上传 | 在文本框输入 XSS 载荷;上传非法扩展名/超大文件 | 输入被校验/转义,非法文件被阻断 | 输入和文件验证均成立 | P1 |
+| S-05 | 安全 | 会话过期与注销后重放 | 登录后等待过期,或主动登出后重放令牌 | 令牌不可再用 | 会话治理有效,日志可追踪 | P1 |
+
+### 性能、接口与回归用例
+
+| 编号 | 类别 | 场景 | 步骤 | 预期结果 | 判定标准 | 优先级 |
+|---|---|---|---|---|---|---|
+| P-01 | 性能 | 200 并发登录 | 使用 k6 并发压测登录接口 | 成功率与时延满足基准 | p95、错误率、CPU/内存均在阈值内 | P1 |
+| P-02 | 性能 | 50,000 行考勤导入 | 上传大文件并监控任务执行 | 请求立即返回,后台稳定执行并产出错误明细 | 吞吐、时长、内存占用、错误处理可接受 | P1 |
+| P-03 | 性能 | 10,000 行报表导出 | 发起导出并观察任务状态与下载 | 导出成功,未阻塞前台请求 | 长任务机制正确,不导致接口超时 | P1 |
+| P-04 | 可靠性 | MQ 失败与重试 | 模拟消息消费失败/网络抖动 | 自动重试;超过阈值进入死信并可见 | 任务不丢失,失败原因可追踪 | P1 |
+| P-05 | 可靠性 | Webhook 目标端间歇失败 | 开启 5xx 故障注入,观察重试与幂等 | 自动重试;重复回调不产生重复业务后果 | 事件幂等、重试退避和审计正确 | P1 |
+| I-01 | 接口 | OpenAPI 契约校验 | 用 openapi.yaml 驱动契约测试或生成客户端 | 契约与实现一致 | 无 doc drift,字段定义精确 | P1 |
+| I-02 | 接口 | ZAP API Scan | 使用 OpenAPI 文档跑 ZAP API Scan | 不出现高危告警;中低危可解释 | 结果可复现,可形成证据 | P1 |
+| I-03 | 接口 | CSV 导入模板兼容性 | 用标准模板、缺字段模板、乱序字段模板分别导入 | 标准成功;异常模板给出明确错误 | 兼容与错误诊断能力正确 | P2 |
+| R-01 | 回归 | 数据库迁移后冒烟 | 空库迁移、造数、执行核心 8 条 smoke | 核心流程全部通过 | 迁移脚本与初始数据可重复 | P1 |
+| R-02 | 回归 | 备份恢复后再执行冒烟 | `pg_dump/restore` 恢复后跑 smoke | 恢复环境可正常运行 | 备份真实可用,非纸面方案 | P1 |
+
+上传的可靠性案例中出现的**账号锁定、会话时效、越权、删除完整性、危险操作确认、回滚**等点,建议全部列为 **P1**;这些点最能体现软件检测工程师在功能、异常、安全和可靠性四个方向上的综合能力。fileciteturn0file0
+
+## 交付物与最终AI Prompt
+
+上传的委托测试申请表与真实报告案例共同提示了一个重要事实:真正便于检测的交付物,从来不只是“一个可运行程序”,而是**代码 + 文档 + 环境 + 脚本 + 数据 + 账号 + 说明**的组合。因此,样本程序的交付目录必须模仿真实送检与检测工作所需的材料组织方式。fileciteturn0file3 fileciteturn0file4 fileciteturn0file0
+
+### 交付物清单
+
+| 交付物 | 内容要求 | 用途 |
+|---|---|---|
+| 源代码仓库 | 前端、后端、配置、测试、脚本、文档完整 monorepo | 作为被测软件主体 |
+| OpenAPI 文档 | `openapi.yaml` + Swagger UI | 接口检测、契约测试、安全扫描 |
+| 数据库初始化脚本 | Flyway 迁移脚本、基础字典数据、示例账号 | 库结构复现与冒烟 |
+| 测试数据生成脚本 | 固定种子生成员工、候选人、考勤、绩效、薪酬期间数据 | 性能测试、回归测试、边界测试 |
+| 自动化测试脚本 | 单元、集成、E2E、性能、安全扫描脚本 | 快速重复检测 |
+| 部署脚本 | Dockerfile、docker-compose.yml、启动/停止脚本 | 环境快速搭建 |
+| 使用说明文档 | README、启动说明、默认账号、角色矩阵、常见问题 | 工程师接手与操作 |
+| 检测说明文档 | 功能清单、测试重点、故障注入说明、预期结果说明 | 内部模拟检测执行依据 |
+| 备份恢复脚本 | `backup.sh`、`restore.sh` | 可靠性与恢复测试 |
+| 任务与日志说明 | 导入导出任务表、日志字段、审计表说明 | 证据采集与问题复现 |
+
+### 建议 Git 仓库结构
+
+```text
+hr-lab-sample/
+├─ README.md
+├─ docs/
+│  ├─ architecture.md
+│  ├─ business-modules.md
+│  ├─ openapi.yaml
+│  ├─ testing-guide.md
+│  ├─ deployment-guide.md
+│  ├─ fault-injection-guide.md
+│  └─ sample-accounts.md
+├─ backend/
+│  ├─ pom.xml
+│  └─ src/
+├─ frontend/
+│  ├─ package.json
+│  └─ src/
+├─ deploy/
+│  ├─ docker-compose.yml
+│  ├─ backend.Dockerfile
+│  └─ frontend.Dockerfile
+├─ scripts/
+│  ├─ dev-up.sh
+│  ├─ dev-down.sh
+│  ├─ seed-demo-data.sh
+│  ├─ backup.sh
+│  ├─ restore.sh
+│  └─ run-smoke.sh
+├─ tests/
+│  ├─ e2e/
+│  ├─ perf/
+│  ├─ security/
+│  └─ contract/
+└─ .github/
+   └─ workflows/
+      └─ ci.yml
+```

+ 119 - 0
README.md

@@ -0,0 +1,119 @@
+# 组织人事管理平台
+
+这是一个面向内部软件测评模拟检测的企业人力资源管理系统样本程序。项目采用单企业、单租户、前后端分离、模块化单体架构,覆盖员工档案、招聘、入离职、考勤、薪酬、绩效、权限审计、报表导出、接口集成等业务。
+
+## 技术栈
+
+- 后端:Java 21、Spring Boot 3.5、Spring Web、Validation、Security、Data JPA、Actuator、Flyway、PostgreSQL、Redis、RabbitMQ
+- 前端:Vue 3、TypeScript、Vite、Pinia、Vue Router、Element Plus
+- 测试:JUnit 5、Testcontainers、Playwright、k6、ZAP API Scan
+- 部署:Dockerfile、Docker Compose、GitHub Actions
+
+## 启动方式
+
+```bash
+scripts/dev-up.sh
+```
+
+启动后访问:
+
+- 前端:http://localhost:5173
+- 后端:http://localhost:8080
+- Swagger UI:http://localhost:8080/swagger-ui.html
+- 健康检查:http://localhost:8080/actuator/health
+
+停止:
+
+```bash
+scripts/dev-down.sh
+```
+
+## 默认账号
+
+默认密码均为 `ChangeMe123!`。
+
+| 账号 | 角色 |
+|---|---|
+| sys_admin | SYSTEM_ADMIN |
+| hr_admin | HR_ADMIN |
+| recruiter_01 | RECRUITER |
+| manager_01 | DEPT_MANAGER |
+| payroll_admin | PAYROLL_ADMIN |
+| employee_0001 | EMPLOYEE_SELF |
+| auditor_01 | AUDITOR |
+| integration_client | INTEGRATION_CLIENT |
+
+登录页不提供账号密码快捷填充。完整功能验收建议手动输入 `sys_admin`;人事权限边界测试建议使用 `hr_admin`;薪酬、审计、集成等模块请分别使用对应角色账号验证最小权限。
+
+## 模块清单
+
+- 员工档案:部门树、岗位、员工主档、编辑、转岗、离职流程、归档、附件元数据、敏感字段脱敏。
+- 招聘:招聘需求、候选人、面试安排、面试通过/不通过/未参加、Offer 接受/拒绝、转入职、未到岗。
+- 入离职:后台新建入职单和离职单、清单勾选、账号激活、离职交接、账号停用、流程取消。
+- 考勤:班次、签到签退、异常识别、请假申请、同意/拒绝审批、考勤记录修正、CSV 导入任务。
+- 薪酬:期间、薪资项目、部门/员工薪酬变量、公式核算、复核、审批、锁定、工资单发布。
+- 绩效:周期发布与编辑、模板权重校验、目标、自评、经理评分、绩效记录修正、分布统计。
+- 权限审计:JWT、Refresh Token、登录锁定、角色权限、数据范围、审计 hash 链。
+- 报表导出:人员结构、招聘漏斗、考勤汇总、薪酬汇总、绩效分布、CSV/XLSX 异步导出;前端下载使用带令牌的文件请求。
+- 接口集成:受限查询、签名校验、幂等键、Webhook 事件。
+- 权限管理:角色、权限、角色权限关系、数据范围配置。
+- 任务监控:导入任务、导出任务、集成事件、失败任务摘要。
+
+## 测试方式
+
+后端单元测试:
+
+```bash
+cd backend
+./mvnw test
+```
+
+前端构建:
+
+```bash
+cd frontend
+npm ci
+npm run build
+```
+
+冒烟测试:
+
+```bash
+scripts/run-smoke.sh
+```
+
+性能测试:
+
+```bash
+scripts/run-k6.sh tests/perf/login.js
+scripts/run-k6.sh tests/perf/employee-search.js
+```
+
+安全扫描:
+
+```bash
+scripts/run-zap-api-scan.sh
+```
+
+## 故障注入
+
+`lab` profile 支持通过环境变量控制故障点:
+
+- `LAB_EXPORT_DELAY_MS`:导出额外延迟
+- `LAB_PAYROLL_DELAY_MS`:薪酬核算额外延迟
+- `LAB_WEBHOOK_FORCE_5XX`:Webhook 接收端返回失败
+- `LAB_FORMULA_EXCEPTION`:薪酬公式异常
+- `LAB_CACHE_DISABLED`:关闭幂等键缓存写入
+
+## 备份恢复
+
+```bash
+scripts/backup.sh
+scripts/restore.sh storage/backups/backup.sql
+```
+
+## 常见问题
+
+- 首次启动会生成 2000 名员工、500 名候选人和 180 天考勤数据,初始化需要一些时间。
+- 如果端口被占用,请修改 `deploy/docker-compose.yml` 的端口映射。
+- 如果 Docker 未启动,后端可用本地 Maven 构建,前端可用本地 Node 构建。

+ 1 - 0
backend/.mvn/wrapper/maven-wrapper.properties

@@ -0,0 +1 @@
+distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.11/apache-maven-3.9.11-bin.zip

+ 10 - 0
backend/mvnw

@@ -0,0 +1,10 @@
+#!/usr/bin/env sh
+set -eu
+if command -v mvn >/dev/null 2>&1; then
+  exec mvn "$@"
+fi
+if command -v docker >/dev/null 2>&1; then
+  exec docker run --rm -v "$PWD":/workspace -w /workspace maven:3.9.11-eclipse-temurin-21 mvn "$@"
+fi
+echo "Maven or Docker is required to run this build." >&2
+exit 1

+ 13 - 0
backend/mvnw.cmd

@@ -0,0 +1,13 @@
+@echo off
+where mvn >nul 2>nul
+if %ERRORLEVEL%==0 (
+  mvn %*
+  exit /b %ERRORLEVEL%
+)
+where docker >nul 2>nul
+if %ERRORLEVEL%==0 (
+  docker run --rm -v "%cd%":/workspace -w /workspace maven:3.9.11-eclipse-temurin-21 mvn %*
+  exit /b %ERRORLEVEL%
+)
+echo Maven or Docker is required to run this build. 1>&2
+exit /b 1

+ 167 - 0
backend/pom.xml

@@ -0,0 +1,167 @@
+<?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.5.14</version>
+        <relativePath/>
+    </parent>
+
+    <groupId>com.example</groupId>
+    <artifactId>organization-people-platform</artifactId>
+    <version>1.0.0</version>
+    <name>organization-people-platform</name>
+
+    <properties>
+        <java.version>21</java.version>
+        <springdoc.version>2.8.17</springdoc.version>
+        <testcontainers.version>1.21.4</testcontainers.version>
+        <java-jwt.version>4.5.0</java-jwt.version>
+        <commons-csv.version>1.14.1</commons-csv.version>
+        <bcprov.version>1.81</bcprov.version>
+    </properties>
+
+    <dependencyManagement>
+        <dependencies>
+            <dependency>
+                <groupId>org.testcontainers</groupId>
+                <artifactId>testcontainers-bom</artifactId>
+                <version>${testcontainers.version}</version>
+                <type>pom</type>
+                <scope>import</scope>
+            </dependency>
+        </dependencies>
+    </dependencyManagement>
+
+    <dependencies>
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-web</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>
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-data-jpa</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-actuator</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-data-redis</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-amqp</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.flywaydb</groupId>
+            <artifactId>flyway-database-postgresql</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.postgresql</groupId>
+            <artifactId>postgresql</artifactId>
+            <scope>runtime</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.springdoc</groupId>
+            <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
+            <version>${springdoc.version}</version>
+        </dependency>
+        <dependency>
+            <groupId>com.auth0</groupId>
+            <artifactId>java-jwt</artifactId>
+            <version>${java-jwt.version}</version>
+        </dependency>
+        <dependency>
+            <groupId>org.apache.commons</groupId>
+            <artifactId>commons-csv</artifactId>
+            <version>${commons-csv.version}</version>
+        </dependency>
+        <dependency>
+            <groupId>org.bouncycastle</groupId>
+            <artifactId>bcprov-jdk18on</artifactId>
+            <version>${bcprov.version}</version>
+        </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>
+        <dependency>
+            <groupId>org.testcontainers</groupId>
+            <artifactId>junit-jupiter</artifactId>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.testcontainers</groupId>
+            <artifactId>postgresql</artifactId>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.testcontainers</groupId>
+            <artifactId>rabbitmq</artifactId>
+            <scope>test</scope>
+        </dependency>
+    </dependencies>
+
+    <build>
+        <plugins>
+            <plugin>
+                <groupId>org.springframework.boot</groupId>
+                <artifactId>spring-boot-maven-plugin</artifactId>
+            </plugin>
+            <plugin>
+                <groupId>org.apache.maven.plugins</groupId>
+                <artifactId>maven-compiler-plugin</artifactId>
+                <configuration>
+                    <release>21</release>
+                </configuration>
+            </plugin>
+            <plugin>
+                <groupId>org.apache.maven.plugins</groupId>
+                <artifactId>maven-surefire-plugin</artifactId>
+                <configuration>
+                    <includes>
+                        <include>**/*Test.java</include>
+                    </includes>
+                </configuration>
+            </plugin>
+            <plugin>
+                <groupId>org.apache.maven.plugins</groupId>
+                <artifactId>maven-failsafe-plugin</artifactId>
+                <configuration>
+                    <includes>
+                        <include>**/*IT.java</include>
+                    </includes>
+                </configuration>
+                <executions>
+                    <execution>
+                        <goals>
+                            <goal>integration-test</goal>
+                            <goal>verify</goal>
+                        </goals>
+                    </execution>
+                </executions>
+            </plugin>
+        </plugins>
+    </build>
+</project>

+ 19 - 0
backend/src/main/java/com/example/hrlab/HrLabApplication.java

@@ -0,0 +1,19 @@
+package com.example.hrlab;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
+import org.springframework.scheduling.annotation.EnableAsync;
+import org.springframework.scheduling.annotation.EnableScheduling;
+import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
+
+@SpringBootApplication
+@ConfigurationPropertiesScan
+@EnableMethodSecurity
+@EnableAsync
+@EnableScheduling
+public class HrLabApplication {
+    public static void main(String[] args) {
+        SpringApplication.run(HrLabApplication.class, args);
+    }
+}

+ 124 - 0
backend/src/main/java/com/example/hrlab/attendance/AttendanceAdminController.java

@@ -0,0 +1,124 @@
+package com.example.hrlab.attendance;
+
+import com.example.hrlab.audit.AuditService;
+import com.example.hrlab.common.ApiResponse;
+import com.example.hrlab.common.BusinessException;
+import com.example.hrlab.common.ErrorCodes;
+import com.example.hrlab.common.Rows;
+import jakarta.validation.Valid;
+import java.util.Map;
+import org.springframework.http.HttpStatus;
+import org.springframework.jdbc.core.JdbcTemplate;
+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.PutMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+@RequestMapping("/api/v1/attendance")
+public class AttendanceAdminController {
+    private final JdbcTemplate jdbcTemplate;
+    private final AuditService auditService;
+    private final LeaveValidator leaveValidator;
+
+    public AttendanceAdminController(JdbcTemplate jdbcTemplate, AuditService auditService, LeaveValidator leaveValidator) {
+        this.jdbcTemplate = jdbcTemplate;
+        this.auditService = auditService;
+        this.leaveValidator = leaveValidator;
+    }
+
+    @GetMapping("/records/{id}")
+    @PreAuthorize("isAuthenticated()")
+    public ApiResponse<Map<String, Object>> record(@PathVariable Long id) {
+        return ApiResponse.ok(find("attendance_records", id));
+    }
+
+    @GetMapping("/shifts/{id}")
+    @PreAuthorize("isAuthenticated()")
+    public ApiResponse<Map<String, Object>> shift(@PathVariable Long id) {
+        return ApiResponse.ok(find("shifts", id));
+    }
+
+    @PutMapping("/shifts/{id}")
+    @PreAuthorize("hasAnyRole('SYSTEM_ADMIN','HR_ADMIN')")
+    public ApiResponse<Map<String, Object>> updateShift(@PathVariable Long id, @Valid @RequestBody ShiftRequest request) {
+        Map<String, Object> before = find("shifts", id);
+        jdbcTemplate.update("""
+                UPDATE shifts SET shift_code = ?, shift_name = ?, start_time = ?, end_time = ?, cross_day = ?, updated_at = now()
+                WHERE id = ?
+                """, request.shiftCode(), request.shiftName(), request.startTime(), request.endTime(),
+                Boolean.TRUE.equals(request.crossDay()), id);
+        auditService.record("SHIFT_UPDATE", "shifts", id, before, request);
+        return ApiResponse.ok(find("shifts", id));
+    }
+
+    @DeleteMapping("/shifts/{id}")
+    @PreAuthorize("hasAnyRole('SYSTEM_ADMIN','HR_ADMIN')")
+    public ApiResponse<Map<String, Object>> deleteShift(@PathVariable Long id) {
+        Map<String, Object> before = find("shifts", id);
+        jdbcTemplate.update("DELETE FROM shifts WHERE id = ?", id);
+        auditService.record("SHIFT_DELETE", "shifts", id, before, Map.of("deleted", true));
+        return ApiResponse.ok(Map.of("deleted", true));
+    }
+
+    @GetMapping("/overtime-requests/{id}")
+    @PreAuthorize("isAuthenticated()")
+    public ApiResponse<Map<String, Object>> overtime(@PathVariable Long id) {
+        return ApiResponse.ok(find("overtime_requests", id));
+    }
+
+    @PutMapping("/overtime-requests/{id}")
+    @PreAuthorize("hasAnyRole('SYSTEM_ADMIN','HR_ADMIN','DEPT_MANAGER')")
+    public ApiResponse<Map<String, Object>> updateOvertime(@PathVariable Long id, @Valid @RequestBody OvertimeRequestDto request) {
+        leaveValidator.validateTime(request.startAt(), request.endAt());
+        Map<String, Object> before = find("overtime_requests", id);
+        jdbcTemplate.update("""
+                UPDATE overtime_requests SET employee_id = ?, start_at = ?, end_at = ?, duration_hours = ?,
+                    status = ?, approver_id = ?, reason = ?, updated_at = now()
+                WHERE id = ?
+                """, request.employeeId(), request.startAt(), request.endAt(),
+                leaveValidator.durationHours(request.startAt(), request.endAt()),
+                request.status() == null ? "SUBMITTED" : request.status(), request.approverId(), request.reason(), id);
+        auditService.record("OVERTIME_UPDATE", "overtime_requests", id, before, request);
+        return ApiResponse.ok(find("overtime_requests", id));
+    }
+
+    @DeleteMapping("/overtime-requests/{id}")
+    @PreAuthorize("hasAnyRole('SYSTEM_ADMIN','HR_ADMIN')")
+    public ApiResponse<Map<String, Object>> deleteOvertime(@PathVariable Long id) {
+        Map<String, Object> before = find("overtime_requests", id);
+        jdbcTemplate.update("DELETE FROM overtime_requests WHERE id = ?", id);
+        auditService.record("OVERTIME_DELETE", "overtime_requests", id, before, Map.of("deleted", true));
+        return ApiResponse.ok(Map.of("deleted", true));
+    }
+
+    @GetMapping("/monthly-summary")
+    @PreAuthorize("hasAnyRole('SYSTEM_ADMIN','HR_ADMIN','DEPT_MANAGER','AUDITOR')")
+    public ApiResponse<Object> monthlySummary(String period) {
+        String safePeriod = period == null || period.isBlank() ? java.time.LocalDate.now().toString().substring(0, 7) : period;
+        return ApiResponse.ok(jdbcTemplate.queryForList("""
+                SELECT e.employee_no, e.display_name,
+                       count(ar.id) total_days,
+                       sum(CASE WHEN ar.anomaly_status = 'NORMAL' THEN 0 ELSE 1 END) anomaly_days,
+                       sum(ar.late_minutes) late_minutes
+                FROM employees e
+                LEFT JOIN attendance_records ar ON ar.employee_id = e.id AND to_char(ar.work_date, 'YYYY-MM') = ?
+                GROUP BY e.employee_no, e.display_name
+                ORDER BY e.employee_no
+                LIMIT 500
+                """, safePeriod));
+    }
+
+    private Map<String, Object> find(String table, Long id) {
+        return jdbcTemplate.query("SELECT * FROM " + table + " WHERE id = ?", rs -> {
+            if (!rs.next()) {
+                throw new BusinessException(ErrorCodes.NOT_FOUND, table + " not found", HttpStatus.NOT_FOUND);
+            }
+            return Rows.map(rs);
+        }, id);
+    }
+}

+ 294 - 0
backend/src/main/java/com/example/hrlab/attendance/AttendanceController.java

@@ -0,0 +1,294 @@
+package com.example.hrlab.attendance;
+
+import com.example.hrlab.audit.AuditService;
+import com.example.hrlab.common.ApiResponse;
+import com.example.hrlab.common.BusinessException;
+import com.example.hrlab.common.ErrorCodes;
+import com.example.hrlab.common.PageResponse;
+import com.example.hrlab.common.Rows;
+import com.example.hrlab.config.AppProperties;
+import com.example.hrlab.security.DataScopeService;
+import com.example.hrlab.security.SecurityUtils;
+import jakarta.validation.Valid;
+import java.math.BigDecimal;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.time.Duration;
+import java.util.Map;
+import org.springframework.http.HttpStatus;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.security.access.prepost.PreAuthorize;
+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.DeleteMapping;
+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.RequestPart;
+import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.multipart.MultipartFile;
+
+@RestController
+@RequestMapping("/api/v1")
+public class AttendanceController {
+    private final JdbcTemplate jdbcTemplate;
+    private final DataScopeService dataScopeService;
+    private final LeaveValidator leaveValidator;
+    private final AuditService auditService;
+    private final AppProperties properties;
+
+    public AttendanceController(JdbcTemplate jdbcTemplate, DataScopeService dataScopeService, LeaveValidator leaveValidator,
+                                AuditService auditService, AppProperties properties) {
+        this.jdbcTemplate = jdbcTemplate;
+        this.dataScopeService = dataScopeService;
+        this.leaveValidator = leaveValidator;
+        this.auditService = auditService;
+        this.properties = properties;
+    }
+
+    @GetMapping("/attendance/records")
+    @PreAuthorize("isAuthenticated()")
+    public ApiResponse<PageResponse<Map<String, Object>>> records(@RequestParam(defaultValue = "1") int page,
+                                                                  @RequestParam(defaultValue = "20") int size) {
+        return ApiResponse.ok(page("attendance_records", "id DESC", page, size));
+    }
+
+    @PostMapping("/attendance/records")
+    @PreAuthorize("hasAnyRole('SYSTEM_ADMIN','HR_ADMIN','DEPT_MANAGER','EMPLOYEE_SELF')")
+    public ApiResponse<Map<String, Object>> createRecord(@Valid @RequestBody AttendanceRecordRequest request) {
+        dataScopeService.requireEmployeeAccess(request.employeeId());
+        Long id = jdbcTemplate.queryForObject("""
+                INSERT INTO attendance_records(employee_id, work_date, shift_code, clock_in_at, clock_out_at,
+                    late_minutes, early_leave_minutes, anomaly_status, manual_adjusted, adjustment_reason)
+                VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id
+                """, Long.class, request.employeeId(), request.workDate(), request.shiftCode(), request.clockInAt(),
+                request.clockOutAt(), lateMinutes(request), 0, anomalyStatus(request),
+                Boolean.TRUE.equals(request.manualAdjusted()), request.adjustmentReason());
+        auditService.record(Boolean.TRUE.equals(request.manualAdjusted()) ? "ATTENDANCE_MANUAL_ADJUST" : "ATTENDANCE_CREATE",
+                "attendance_records", id, null, request);
+        return ApiResponse.ok(find("attendance_records", id, "考勤记录不存在"));
+    }
+
+    @PutMapping("/attendance/records/{id}")
+    @PreAuthorize("hasAnyRole('SYSTEM_ADMIN','HR_ADMIN','DEPT_MANAGER')")
+    public ApiResponse<Map<String, Object>> updateRecord(@PathVariable Long id, @Valid @RequestBody AttendanceRecordRequest request) {
+        dataScopeService.requireEmployeeAccess(request.employeeId());
+        Map<String, Object> before = find("attendance_records", id, "考勤记录不存在");
+        jdbcTemplate.update("""
+                UPDATE attendance_records SET employee_id = ?, work_date = ?, shift_code = ?, clock_in_at = ?, clock_out_at = ?,
+                    late_minutes = ?, early_leave_minutes = ?, anomaly_status = ?, manual_adjusted = ?, adjustment_reason = ?,
+                    version = version + 1, updated_at = now()
+                WHERE id = ?
+                """, request.employeeId(), request.workDate(), request.shiftCode(), request.clockInAt(),
+                request.clockOutAt(), lateMinutes(request), 0, anomalyStatus(request),
+                Boolean.TRUE.equals(request.manualAdjusted()), request.adjustmentReason(), id);
+        auditService.record("ATTENDANCE_UPDATE", "attendance_records", id, before, request);
+        return ApiResponse.ok(find("attendance_records", id, "考勤记录不存在"));
+    }
+
+    @DeleteMapping("/attendance/records/{id}")
+    @PreAuthorize("hasAnyRole('SYSTEM_ADMIN','HR_ADMIN')")
+    public ApiResponse<Map<String, Object>> deleteRecord(@PathVariable Long id) {
+        Map<String, Object> before = find("attendance_records", id, "考勤记录不存在");
+        jdbcTemplate.update("DELETE FROM attendance_records WHERE id = ?", id);
+        auditService.record("ATTENDANCE_DELETE", "attendance_records", id, before, Map.of("deleted", true));
+        return ApiResponse.ok(Map.of("deleted", true));
+    }
+
+    @GetMapping("/leave-requests")
+    @PreAuthorize("isAuthenticated()")
+    public ApiResponse<PageResponse<Map<String, Object>>> leaveRequests(@RequestParam(defaultValue = "1") int page,
+                                                                        @RequestParam(defaultValue = "20") int size) {
+        return ApiResponse.ok(page("leave_requests", "id DESC", page, size));
+    }
+
+    @PostMapping("/leave-requests")
+    @PreAuthorize("hasAnyRole('SYSTEM_ADMIN','HR_ADMIN','DEPT_MANAGER','EMPLOYEE_SELF')")
+    public ApiResponse<Map<String, Object>> createLeave(@Valid @RequestBody LeaveRequestDto request) {
+        dataScopeService.requireEmployeeAccess(request.employeeId());
+        leaveValidator.validateNoOverlap(request.employeeId(), request.startAt(), request.endAt(), null);
+        BigDecimal hours = leaveValidator.durationHours(request.startAt(), request.endAt());
+        Long id = jdbcTemplate.queryForObject("""
+                INSERT INTO leave_requests(employee_id, leave_type, start_at, end_at, duration_hours, status, approver_id, reason)
+                VALUES (?, ?, ?, ?, ?, ?, ?, ?) RETURNING id
+                """, Long.class, request.employeeId(), request.leaveType(), request.startAt(), request.endAt(), hours,
+                defaultString(request.status(), "SUBMITTED"), request.approverId(), request.reason());
+        auditService.record("LEAVE_CREATE", "leave_requests", id, null, request);
+        return ApiResponse.ok(find("leave_requests", id, "请假申请不存在"));
+    }
+
+    @PutMapping("/leave-requests/{id}")
+    @PreAuthorize("hasAnyRole('SYSTEM_ADMIN','HR_ADMIN','DEPT_MANAGER')")
+    public ApiResponse<Map<String, Object>> updateLeave(@PathVariable Long id, @Valid @RequestBody LeaveRequestDto request) {
+        dataScopeService.requireEmployeeAccess(request.employeeId());
+        leaveValidator.validateNoOverlap(request.employeeId(), request.startAt(), request.endAt(), id);
+        Map<String, Object> before = find("leave_requests", id, "请假申请不存在");
+        jdbcTemplate.update("""
+                UPDATE leave_requests SET employee_id = ?, leave_type = ?, start_at = ?, end_at = ?, duration_hours = ?,
+                    status = ?, approver_id = ?, reason = ?, version = version + 1, updated_at = now()
+                WHERE id = ?
+                """, request.employeeId(), request.leaveType(), request.startAt(), request.endAt(),
+                leaveValidator.durationHours(request.startAt(), request.endAt()), defaultString(request.status(), "SUBMITTED"),
+                request.approverId(), request.reason(), id);
+        auditService.record("LEAVE_UPDATE", "leave_requests", id, before, request);
+        return ApiResponse.ok(find("leave_requests", id, "请假申请不存在"));
+    }
+
+    @PostMapping("/leave-requests/{id}/approve")
+    @PreAuthorize("hasAnyRole('SYSTEM_ADMIN','HR_ADMIN','DEPT_MANAGER')")
+    public ApiResponse<Map<String, Object>> approveLeave(@PathVariable Long id) {
+        Map<String, Object> before = find("leave_requests", id, "请假申请不存在");
+        jdbcTemplate.update("UPDATE leave_requests SET status = 'APPROVED', approver_id = ?, version = version + 1, updated_at = now() WHERE id = ?",
+                SecurityUtils.currentUser().orElseThrow().employeeId(), id);
+        auditService.record("LEAVE_APPROVE", "leave_requests", id, before, Map.of("status", "APPROVED"));
+        return ApiResponse.ok(find("leave_requests", id, "请假申请不存在"));
+    }
+
+    @PostMapping("/leave-requests/{id}/reject")
+    @PreAuthorize("hasAnyRole('SYSTEM_ADMIN','HR_ADMIN','DEPT_MANAGER')")
+    public ApiResponse<Map<String, Object>> rejectLeave(@PathVariable Long id,
+                                                        @RequestBody(required = false) DecisionRequest request) {
+        Map<String, Object> before = find("leave_requests", id, "请假申请不存在");
+        String reason = request == null || request.reason() == null ? "" : request.reason();
+        jdbcTemplate.update("""
+                UPDATE leave_requests SET status = 'REJECTED', approver_id = ?, reason = ?,
+                    version = version + 1, updated_at = now()
+                WHERE id = ?
+                """, SecurityUtils.currentUser().orElseThrow().employeeId(), reason, id);
+        auditService.record("LEAVE_REJECT", "leave_requests", id, before, Map.of("status", "REJECTED", "reason", reason));
+        return ApiResponse.ok(find("leave_requests", id, "请假申请不存在"));
+    }
+
+    @DeleteMapping("/leave-requests/{id}")
+    @PreAuthorize("hasAnyRole('SYSTEM_ADMIN','HR_ADMIN')")
+    public ApiResponse<Map<String, Object>> deleteLeave(@PathVariable Long id) {
+        Map<String, Object> before = find("leave_requests", id, "请假申请不存在");
+        jdbcTemplate.update("DELETE FROM leave_requests WHERE id = ?", id);
+        auditService.record("LEAVE_DELETE", "leave_requests", id, before, Map.of("deleted", true));
+        return ApiResponse.ok(Map.of("deleted", true));
+    }
+
+    @GetMapping("/attendance/overtime-requests")
+    @PreAuthorize("isAuthenticated()")
+    public ApiResponse<PageResponse<Map<String, Object>>> overtimeRequests(@RequestParam(defaultValue = "1") int page,
+                                                                           @RequestParam(defaultValue = "20") int size) {
+        return ApiResponse.ok(page("overtime_requests", "id DESC", page, size));
+    }
+
+    @PostMapping("/attendance/overtime-requests")
+    @PreAuthorize("hasAnyRole('SYSTEM_ADMIN','HR_ADMIN','DEPT_MANAGER','EMPLOYEE_SELF')")
+    public ApiResponse<Map<String, Object>> createOvertime(@Valid @RequestBody OvertimeRequestDto request) {
+        dataScopeService.requireEmployeeAccess(request.employeeId());
+        leaveValidator.validateTime(request.startAt(), request.endAt());
+        Long id = jdbcTemplate.queryForObject("""
+                INSERT INTO overtime_requests(employee_id, start_at, end_at, duration_hours, status, approver_id, reason)
+                VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING id
+                """, Long.class, request.employeeId(), request.startAt(), request.endAt(),
+                leaveValidator.durationHours(request.startAt(), request.endAt()), defaultString(request.status(), "SUBMITTED"),
+                request.approverId(), request.reason());
+        auditService.record("OVERTIME_CREATE", "overtime_requests", id, null, request);
+        return ApiResponse.ok(find("overtime_requests", id, "加班申请不存在"));
+    }
+
+    @PostMapping("/attendance/overtime-requests/{id}/approve")
+    @PreAuthorize("hasAnyRole('SYSTEM_ADMIN','HR_ADMIN','DEPT_MANAGER')")
+    public ApiResponse<Map<String, Object>> approveOvertime(@PathVariable Long id) {
+        Map<String, Object> before = find("overtime_requests", id, "加班申请不存在");
+        jdbcTemplate.update("UPDATE overtime_requests SET status = 'APPROVED', approver_id = ?, updated_at = now() WHERE id = ?",
+                SecurityUtils.currentUser().orElseThrow().employeeId(), id);
+        auditService.record("OVERTIME_APPROVE", "overtime_requests", id, before, Map.of("status", "APPROVED"));
+        return ApiResponse.ok(find("overtime_requests", id, "加班申请不存在"));
+    }
+
+    @PostMapping("/attendance/import-jobs")
+    @PreAuthorize("hasAnyRole('SYSTEM_ADMIN','HR_ADMIN')")
+    public ApiResponse<Map<String, Object>> importAttendance(@RequestPart("file") MultipartFile file) throws Exception {
+        validateCsv(file);
+        Files.createDirectories(Path.of(properties.storage().importDir()));
+        Path path = Path.of(properties.storage().importDir(), System.currentTimeMillis() + "-" + cleanFileName(file.getOriginalFilename()));
+        file.transferTo(path);
+        long lineCount;
+        try (var lines = Files.lines(path)) {
+            lineCount = Math.max(0, lines.count() - 1);
+        }
+        Long id = jdbcTemplate.queryForObject("""
+                INSERT INTO import_jobs(job_type, source_file, preview_result_json, status, success_count, fail_count, created_by, finished_at)
+                VALUES ('ATTENDANCE_CSV', ?, ?::jsonb, 'COMPLETED', ?, 0, ?, now()) RETURNING id
+                """, Long.class, path.toString().replace('\\', '/'), "{\"rows\":" + lineCount + "}", lineCount,
+                SecurityUtils.currentUserIdOrNull());
+        auditService.record("ATTENDANCE_IMPORT", "import_jobs", id, null, Map.of("file", file.getOriginalFilename(), "rows", lineCount));
+        return ApiResponse.accepted(find("import_jobs", id, "导入任务不存在"));
+    }
+
+    @GetMapping("/attendance/shifts")
+    @PreAuthorize("isAuthenticated()")
+    public ApiResponse<Object> shifts() {
+        return ApiResponse.ok(jdbcTemplate.queryForList("SELECT * FROM shifts ORDER BY shift_code"));
+    }
+
+    @PostMapping("/attendance/shifts")
+    @PreAuthorize("hasAnyRole('SYSTEM_ADMIN','HR_ADMIN')")
+    public ApiResponse<Map<String, Object>> createShift(@Valid @RequestBody ShiftRequest request) {
+        Long id = jdbcTemplate.queryForObject("""
+                INSERT INTO shifts(shift_code, shift_name, start_time, end_time, cross_day)
+                VALUES (?, ?, ?, ?, ?) RETURNING id
+                """, Long.class, request.shiftCode(), request.shiftName(), request.startTime(), request.endTime(),
+                Boolean.TRUE.equals(request.crossDay()));
+        auditService.record("SHIFT_CREATE", "shifts", id, null, request);
+        return ApiResponse.ok(find("shifts", id, "班次不存在"));
+    }
+
+    private int lateMinutes(AttendanceRecordRequest request) {
+        if (request.clockInAt() == null) {
+            return 0;
+        }
+        return Math.max(0, (int) Duration.between(request.workDate().atTime(9, 0).atOffset(request.clockInAt().getOffset()),
+                request.clockInAt()).toMinutes());
+    }
+
+    private String anomalyStatus(AttendanceRecordRequest request) {
+        if (request.clockInAt() == null || request.clockOutAt() == null) {
+            return "MISSING_CLOCK";
+        }
+        return lateMinutes(request) > 0 ? "LATE" : "NORMAL";
+    }
+
+    private void validateCsv(MultipartFile file) {
+        String name = file.getOriginalFilename() == null ? "" : file.getOriginalFilename().toLowerCase();
+        String contentType = file.getContentType() == null ? "" : file.getContentType().toLowerCase();
+        if (!name.endsWith(".csv") || !(contentType.contains("csv") || contentType.equals("application/vnd.ms-excel") || contentType.equals("text/plain"))) {
+            throw new BusinessException(ErrorCodes.FILE_REJECTED, "仅支持 CSV 文件");
+        }
+        if (file.getSize() <= 0 || file.getSize() > 10 * 1024 * 1024) {
+            throw new BusinessException(ErrorCodes.FILE_REJECTED, "文件大小不符合限制");
+        }
+    }
+
+    private String cleanFileName(String name) {
+        return name == null ? "attendance.csv" : name.replaceAll("[^A-Za-z0-9._-]", "_");
+    }
+
+    private PageResponse<Map<String, Object>> page(String table, String order, int page, int size) {
+        int safePage = Math.max(page, 1);
+        int safeSize = Math.min(Math.max(size, 1), 100);
+        long total = jdbcTemplate.queryForObject("SELECT count(*) FROM " + table, Long.class);
+        var rows = jdbcTemplate.query("SELECT * FROM " + table + " ORDER BY " + order + " LIMIT ? OFFSET ?",
+                (rs, rowNum) -> Rows.map(rs), safeSize, (safePage - 1) * safeSize);
+        return new PageResponse<>(rows, safePage, safeSize, total);
+    }
+
+    private Map<String, Object> find(String table, Long id, String message) {
+        return jdbcTemplate.query("SELECT * FROM " + table + " WHERE id = ?", rs -> {
+            if (!rs.next()) {
+                throw new BusinessException(ErrorCodes.NOT_FOUND, message, HttpStatus.NOT_FOUND);
+            }
+            return Rows.map(rs);
+        }, id);
+    }
+
+    private String defaultString(String value, String defaultValue) {
+        return value == null || value.isBlank() ? defaultValue : value;
+    }
+}

+ 11 - 0
backend/src/main/java/com/example/hrlab/attendance/AttendanceRecordRequest.java

@@ -0,0 +1,11 @@
+package com.example.hrlab.attendance;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import java.time.LocalDate;
+import java.time.OffsetDateTime;
+
+public record AttendanceRecordRequest(@NotNull Long employeeId, @NotNull LocalDate workDate, @NotBlank String shiftCode,
+                                      OffsetDateTime clockInAt, OffsetDateTime clockOutAt,
+                                      Boolean manualAdjusted, String adjustmentReason) {
+}

+ 4 - 0
backend/src/main/java/com/example/hrlab/attendance/DecisionRequest.java

@@ -0,0 +1,4 @@
+package com.example.hrlab.attendance;
+
+public record DecisionRequest(String reason) {
+}

+ 10 - 0
backend/src/main/java/com/example/hrlab/attendance/LeaveRequestDto.java

@@ -0,0 +1,10 @@
+package com.example.hrlab.attendance;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import java.time.OffsetDateTime;
+
+public record LeaveRequestDto(@NotNull Long employeeId, @NotBlank String leaveType,
+                              @NotNull OffsetDateTime startAt, @NotNull OffsetDateTime endAt,
+                              String status, Long approverId, String reason) {
+}

+ 54 - 0
backend/src/main/java/com/example/hrlab/attendance/LeaveValidator.java

@@ -0,0 +1,54 @@
+package com.example.hrlab.attendance;
+
+import com.example.hrlab.common.BusinessException;
+import com.example.hrlab.common.ErrorCodes;
+import java.time.Duration;
+import java.time.OffsetDateTime;
+import java.util.Optional;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.stereotype.Service;
+
+@Service
+public class LeaveValidator {
+    private final Optional<JdbcTemplate> jdbcTemplate;
+
+    public LeaveValidator(Optional<JdbcTemplate> jdbcTemplate) {
+        this.jdbcTemplate = jdbcTemplate;
+    }
+
+    public void validateTime(OffsetDateTime startAt, OffsetDateTime endAt) {
+        if (startAt == null || endAt == null || !startAt.isBefore(endAt)) {
+            throw new BusinessException(ErrorCodes.BUSINESS_RULE_VIOLATION, "请假开始时间必须早于结束时间");
+        }
+    }
+
+    public void validateNoOverlap(Long employeeId, OffsetDateTime startAt, OffsetDateTime endAt, Long excludedId) {
+        validateTime(startAt, endAt);
+        jdbcTemplate.ifPresent(jdbc -> {
+            Long count;
+            if (excludedId == null) {
+                count = jdbc.queryForObject("""
+                        SELECT count(*) FROM leave_requests
+                        WHERE employee_id = ? AND status IN ('SUBMITTED','APPROVED')
+                          AND start_at < ? AND end_at > ?
+                        """, Long.class, employeeId, endAt, startAt);
+            } else {
+                count = jdbc.queryForObject("""
+                        SELECT count(*) FROM leave_requests
+                        WHERE employee_id = ? AND status IN ('SUBMITTED','APPROVED')
+                          AND start_at < ? AND end_at > ?
+                          AND id <> ?
+                        """, Long.class, employeeId, endAt, startAt, excludedId);
+            }
+            if (count != null && count > 0) {
+                throw new BusinessException(ErrorCodes.CONFLICT, "请假时间与已有申请重叠");
+            }
+        });
+    }
+
+    public java.math.BigDecimal durationHours(OffsetDateTime startAt, OffsetDateTime endAt) {
+        validateTime(startAt, endAt);
+        return java.math.BigDecimal.valueOf(Duration.between(startAt, endAt).toMinutes())
+                .divide(java.math.BigDecimal.valueOf(60), 2, java.math.RoundingMode.HALF_UP);
+    }
+}

+ 8 - 0
backend/src/main/java/com/example/hrlab/attendance/OvertimeRequestDto.java

@@ -0,0 +1,8 @@
+package com.example.hrlab.attendance;
+
+import jakarta.validation.constraints.NotNull;
+import java.time.OffsetDateTime;
+
+public record OvertimeRequestDto(@NotNull Long employeeId, @NotNull OffsetDateTime startAt,
+                                 @NotNull OffsetDateTime endAt, String status, Long approverId, String reason) {
+}

+ 9 - 0
backend/src/main/java/com/example/hrlab/attendance/ShiftRequest.java

@@ -0,0 +1,9 @@
+package com.example.hrlab.attendance;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import java.time.LocalTime;
+
+public record ShiftRequest(@NotBlank String shiftCode, @NotBlank String shiftName,
+                           @NotNull LocalTime startTime, @NotNull LocalTime endTime, Boolean crossDay) {
+}

+ 48 - 0
backend/src/main/java/com/example/hrlab/audit/AuditController.java

@@ -0,0 +1,48 @@
+package com.example.hrlab.audit;
+
+import com.example.hrlab.common.ApiResponse;
+import com.example.hrlab.common.PageResponse;
+import com.example.hrlab.common.Rows;
+import java.util.Map;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+@RequestMapping("/api/v1/audit-logs")
+public class AuditController {
+    private final JdbcTemplate jdbcTemplate;
+
+    public AuditController(JdbcTemplate jdbcTemplate) {
+        this.jdbcTemplate = jdbcTemplate;
+    }
+
+    @GetMapping
+    @PreAuthorize("hasAnyRole('SYSTEM_ADMIN','AUDITOR')")
+    public ApiResponse<PageResponse<Map<String, Object>>> list(
+            @RequestParam(defaultValue = "1") int page,
+            @RequestParam(defaultValue = "20") int size,
+            @RequestParam(required = false) String action) {
+        int safePage = Math.max(page, 1);
+        int safeSize = Math.min(Math.max(size, 1), 100);
+        String where = action == null || action.isBlank() ? "" : " WHERE action = ? ";
+        Object[] args = action == null || action.isBlank()
+                ? new Object[]{safeSize, (safePage - 1) * safeSize}
+                : new Object[]{action, safeSize, (safePage - 1) * safeSize};
+        long total = action == null || action.isBlank()
+                ? jdbcTemplate.queryForObject("SELECT count(*) FROM audit_logs", Long.class)
+                : jdbcTemplate.queryForObject("SELECT count(*) FROM audit_logs WHERE action = ?", Long.class, action);
+        var rows = jdbcTemplate.query("""
+                SELECT a.*, ua.username AS "actorUsername",
+                    COALESCE(e.display_name, ua.username, '系统任务') AS "actorDisplayName"
+                FROM audit_logs a
+                LEFT JOIN user_accounts ua ON ua.id = a.actor_user_id
+                LEFT JOIN employees e ON e.id = ua.employee_id
+                """ + where + " ORDER BY a.id DESC LIMIT ? OFFSET ?",
+                (rs, rowNum) -> Rows.map(rs), args);
+        return ApiResponse.ok(new PageResponse<>(rows, safePage, safeSize, total));
+    }
+}

+ 78 - 0
backend/src/main/java/com/example/hrlab/audit/AuditService.java

@@ -0,0 +1,78 @@
+package com.example.hrlab.audit;
+
+import com.example.hrlab.common.Correlation;
+import com.example.hrlab.common.JsonUtils;
+import com.example.hrlab.security.SecurityUtils;
+import jakarta.servlet.http.HttpServletRequest;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.time.Clock;
+import java.time.OffsetDateTime;
+import java.util.HexFormat;
+import java.util.Map;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.stereotype.Service;
+import org.springframework.web.context.request.RequestContextHolder;
+import org.springframework.web.context.request.ServletRequestAttributes;
+
+@Service
+public class AuditService {
+    private final JdbcTemplate jdbcTemplate;
+    private final Clock clock;
+
+    public AuditService(JdbcTemplate jdbcTemplate, Clock clock) {
+        this.jdbcTemplate = jdbcTemplate;
+        this.clock = clock;
+    }
+
+    public void record(String action, String entityType, Object entityId, Object before, Object after) {
+        String beforeJson = JsonUtils.toJson(before == null ? Map.of() : before);
+        String afterJson = JsonUtils.toJson(after == null ? Map.of() : after);
+        String prevHash = jdbcTemplate.query("SELECT record_hash FROM audit_logs ORDER BY id DESC LIMIT 1",
+                rs -> rs.next() ? rs.getString(1) : null);
+        OffsetDateTime occurredAt = OffsetDateTime.now(clock);
+        String correlationId = Correlation.currentId();
+        String recordHash = hash(prevHash, action, entityType, String.valueOf(entityId), beforeJson, afterJson, correlationId, occurredAt.toString());
+        RequestInfo requestInfo = requestInfo();
+        jdbcTemplate.update("""
+                INSERT INTO audit_logs(actor_user_id, action, entity_type, entity_id, before_json, after_json,
+                    ip_addr, user_agent, correlation_id, prev_hash, record_hash, occurred_at)
+                VALUES (?, ?, ?, ?, ?::jsonb, ?::jsonb, ?, ?, ?, ?, ?, ?)
+                """, SecurityUtils.currentUserIdOrNull(), action, entityType, entityId == null ? null : String.valueOf(entityId),
+                beforeJson, afterJson, requestInfo.ip(), requestInfo.userAgent(), correlationId, prevHash, recordHash, occurredAt);
+    }
+
+    public String hash(String prevHash, String action, String entityType, String entityId, String beforeJson,
+                       String afterJson, String correlationId, String occurredAt) {
+        try {
+            MessageDigest digest = MessageDigest.getInstance("SHA-256");
+            String material = String.join("|",
+                    prevHash == null ? "" : prevHash,
+                    action,
+                    entityType,
+                    entityId == null ? "" : entityId,
+                    beforeJson,
+                    afterJson,
+                    correlationId,
+                    occurredAt);
+            return HexFormat.of().formatHex(digest.digest(material.getBytes(StandardCharsets.UTF_8)));
+        } catch (Exception ex) {
+            throw new IllegalStateException("审计哈希计算失败", ex);
+        }
+    }
+
+    private RequestInfo requestInfo() {
+        if (RequestContextHolder.getRequestAttributes() instanceof ServletRequestAttributes attrs) {
+            HttpServletRequest request = attrs.getRequest();
+            String ip = request.getHeader("X-Forwarded-For");
+            if (ip == null || ip.isBlank()) {
+                ip = request.getRemoteAddr();
+            }
+            return new RequestInfo(ip, request.getHeader("User-Agent"));
+        }
+        return new RequestInfo(null, null);
+    }
+
+    private record RequestInfo(String ip, String userAgent) {
+    }
+}

+ 53 - 0
backend/src/main/java/com/example/hrlab/auth/AuthController.java

@@ -0,0 +1,53 @@
+package com.example.hrlab.auth;
+
+import com.example.hrlab.common.ApiResponse;
+import com.example.hrlab.security.SecurityUtils;
+import jakarta.validation.Valid;
+import java.util.Map;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+@RequestMapping("/api/v1/auth")
+public class AuthController {
+    private final AuthService authService;
+    private final JdbcTemplate jdbcTemplate;
+
+    public AuthController(AuthService authService, JdbcTemplate jdbcTemplate) {
+        this.authService = authService;
+        this.jdbcTemplate = jdbcTemplate;
+    }
+
+    @PostMapping("/login")
+    public ApiResponse<LoginResult> login(@Valid @RequestBody LoginRequest request) {
+        return ApiResponse.ok(authService.login(request));
+    }
+
+    @PostMapping("/refresh")
+    public ApiResponse<LoginResult> refresh(@Valid @RequestBody RefreshRequest request) {
+        return ApiResponse.ok(authService.refresh(request));
+    }
+
+    @PostMapping("/logout")
+    public ApiResponse<Map<String, Object>> logout(@RequestBody(required = false) LogoutRequest request) {
+        Long userId = SecurityUtils.currentUser().orElseThrow().userId();
+        authService.logout(userId, request == null ? null : request.refreshToken());
+        return ApiResponse.ok(Map.of("loggedOut", true));
+    }
+
+    @GetMapping("/me")
+    public ApiResponse<Map<String, Object>> me() {
+        return ApiResponse.ok(authService.toUserView(SecurityUtils.currentUser().orElseThrow()));
+    }
+
+    @GetMapping("/roles")
+    @PreAuthorize("hasRole('SYSTEM_ADMIN')")
+    public ApiResponse<Object> roles() {
+        return ApiResponse.ok(jdbcTemplate.queryForList("SELECT role_code, role_name, data_scope FROM roles ORDER BY id"));
+    }
+}

+ 153 - 0
backend/src/main/java/com/example/hrlab/auth/AuthService.java

@@ -0,0 +1,153 @@
+package com.example.hrlab.auth;
+
+import com.example.hrlab.audit.AuditService;
+import com.example.hrlab.common.BusinessException;
+import com.example.hrlab.common.ErrorCodes;
+import com.example.hrlab.config.AppProperties;
+import com.example.hrlab.security.AuthUser;
+import com.example.hrlab.security.AuthUserService;
+import com.example.hrlab.security.JwtService;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.time.Clock;
+import java.time.OffsetDateTime;
+import java.util.Base64;
+import java.util.Map;
+import java.util.UUID;
+import org.springframework.http.HttpStatus;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.security.crypto.password.PasswordEncoder;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+@Service
+public class AuthService {
+    private final JdbcTemplate jdbcTemplate;
+    private final PasswordEncoder passwordEncoder;
+    private final JwtService jwtService;
+    private final AuthUserService authUserService;
+    private final AppProperties properties;
+    private final Clock clock;
+    private final AuditService auditService;
+
+    public AuthService(JdbcTemplate jdbcTemplate, PasswordEncoder passwordEncoder, JwtService jwtService,
+                       AuthUserService authUserService, AppProperties properties, Clock clock, AuditService auditService) {
+        this.jdbcTemplate = jdbcTemplate;
+        this.passwordEncoder = passwordEncoder;
+        this.jwtService = jwtService;
+        this.authUserService = authUserService;
+        this.properties = properties;
+        this.clock = clock;
+        this.auditService = auditService;
+    }
+
+    @Transactional
+    public LoginResult login(LoginRequest request) {
+        Account account = jdbcTemplate.query("""
+                SELECT id, username, password_hash, status, failed_attempts, locked_until
+                FROM user_accounts WHERE username = ?
+                """, rs -> {
+            if (!rs.next()) {
+                throw unauthorized();
+            }
+            return new Account(rs.getLong("id"), rs.getString("username"), rs.getString("password_hash"),
+                    rs.getString("status"), rs.getInt("failed_attempts"), rs.getObject("locked_until", OffsetDateTime.class));
+        }, request.username());
+        OffsetDateTime now = OffsetDateTime.now(clock);
+        if (!"ACTIVE".equals(account.status())) {
+            throw new BusinessException(ErrorCodes.UNAUTHORIZED, "账号不可用", HttpStatus.UNAUTHORIZED);
+        }
+        if (account.lockedUntil() != null && account.lockedUntil().isAfter(now)) {
+            throw new BusinessException(ErrorCodes.UNAUTHORIZED, "账号已临时锁定", HttpStatus.UNAUTHORIZED);
+        }
+        if (!passwordEncoder.matches(request.password(), account.passwordHash())) {
+            int failures = account.failedAttempts() + 1;
+            OffsetDateTime lockedUntil = failures >= properties.security().loginFailureThreshold()
+                    ? now.plus(properties.security().lockDuration()) : null;
+            jdbcTemplate.update("UPDATE user_accounts SET failed_attempts = ?, locked_until = ?, updated_at = now() WHERE id = ?",
+                    failures, lockedUntil, account.id());
+            auditService.record("AUTH_LOGIN_FAILED", "user_accounts", account.id(), Map.of("username", account.username()), Map.of("failedAttempts", failures));
+            throw unauthorized();
+        }
+        jdbcTemplate.update("UPDATE user_accounts SET failed_attempts = 0, locked_until = NULL, last_login_at = ?, updated_at = now() WHERE id = ?",
+                now, account.id());
+        AuthUser user = authUserService.loadByUsername(account.username());
+        String refreshToken = createOpaqueToken();
+        jdbcTemplate.update("""
+                INSERT INTO login_sessions(user_account_id, refresh_token_hash, device_name, issued_at, expires_at)
+                VALUES (?, ?, ?, ?, ?)
+                """, user.userId(), tokenHash(refreshToken), request.deviceName(), now, now.plus(properties.jwt().refreshDuration()));
+        auditService.record("AUTH_LOGIN_SUCCESS", "user_accounts", user.userId(), null, Map.of("username", user.username()));
+        return new LoginResult(jwtService.createAccessToken(user), refreshToken,
+                properties.jwt().accessDuration().toSeconds(), toUserView(user));
+    }
+
+    @Transactional
+    public LoginResult refresh(RefreshRequest request) {
+        String tokenHash = tokenHash(request.refreshToken());
+        Long userId = jdbcTemplate.query("""
+                SELECT user_account_id FROM login_sessions
+                WHERE refresh_token_hash = ? AND revoked_at IS NULL AND expires_at > ?
+                """, rs -> rs.next() ? rs.getLong(1) : null, tokenHash, OffsetDateTime.now(clock));
+        if (userId == null) {
+            throw new BusinessException(ErrorCodes.UNAUTHORIZED, "刷新令牌无效", HttpStatus.UNAUTHORIZED);
+        }
+        AuthUser user = authUserService.loadByUserId(userId);
+        String newRefreshToken = createOpaqueToken();
+        jdbcTemplate.update("UPDATE login_sessions SET revoked_at = ?, updated_at = now() WHERE refresh_token_hash = ?",
+                OffsetDateTime.now(clock), tokenHash);
+        jdbcTemplate.update("""
+                INSERT INTO login_sessions(user_account_id, refresh_token_hash, device_name, issued_at, expires_at)
+                VALUES (?, ?, ?, ?, ?)
+                """, user.userId(), tokenHash(newRefreshToken), request.deviceName(),
+                OffsetDateTime.now(clock), OffsetDateTime.now(clock).plus(properties.jwt().refreshDuration()));
+        auditService.record("AUTH_REFRESH", "user_accounts", user.userId(), null, Map.of("username", user.username()));
+        return new LoginResult(jwtService.createAccessToken(user), newRefreshToken,
+                properties.jwt().accessDuration().toSeconds(), toUserView(user));
+    }
+
+    @Transactional
+    public void logout(Long userId, String refreshToken) {
+        if (refreshToken != null && !refreshToken.isBlank()) {
+            jdbcTemplate.update("UPDATE login_sessions SET revoked_at = ?, updated_at = now() WHERE refresh_token_hash = ? AND user_account_id = ?",
+                    OffsetDateTime.now(clock), tokenHash(refreshToken), userId);
+        } else {
+            jdbcTemplate.update("UPDATE login_sessions SET revoked_at = ?, updated_at = now() WHERE user_account_id = ? AND revoked_at IS NULL",
+                    OffsetDateTime.now(clock), userId);
+        }
+        auditService.record("AUTH_LOGOUT", "user_accounts", userId, null, Map.of("revoked", true));
+    }
+
+    public Map<String, Object> toUserView(AuthUser user) {
+        return Map.of(
+                "userId", user.userId(),
+                "employeeId", user.employeeId() == null ? "" : user.employeeId(),
+                "username", user.username(),
+                "displayName", user.username(),
+                "roles", user.roles(),
+                "dataScopes", user.departmentPath() == null ? java.util.List.of("ALL") : java.util.List.of(user.departmentPath())
+        );
+    }
+
+    public String tokenHash(String token) {
+        try {
+            MessageDigest digest = MessageDigest.getInstance("SHA-256");
+            return Base64.getUrlEncoder().withoutPadding()
+                    .encodeToString(digest.digest(token.getBytes(StandardCharsets.UTF_8)));
+        } catch (Exception ex) {
+            throw new IllegalStateException("令牌哈希失败", ex);
+        }
+    }
+
+    private String createOpaqueToken() {
+        return UUID.randomUUID() + "." + UUID.randomUUID();
+    }
+
+    private BusinessException unauthorized() {
+        return new BusinessException(ErrorCodes.UNAUTHORIZED, "账号或密码错误", HttpStatus.UNAUTHORIZED);
+    }
+
+    private record Account(Long id, String username, String passwordHash, String status, int failedAttempts,
+                           OffsetDateTime lockedUntil) {
+    }
+}

+ 10 - 0
backend/src/main/java/com/example/hrlab/auth/ExternalIdentityProvider.java

@@ -0,0 +1,10 @@
+package com.example.hrlab.auth;
+
+import java.util.Optional;
+
+public interface ExternalIdentityProvider {
+    Optional<ExternalIdentity> authenticate(String username, String credential);
+
+    record ExternalIdentity(String subject, String displayName, String provider) {
+    }
+}

+ 15 - 0
backend/src/main/java/com/example/hrlab/auth/LocalCompatibilityIdentityProvider.java

@@ -0,0 +1,15 @@
+package com.example.hrlab.auth;
+
+import java.util.Optional;
+import org.springframework.stereotype.Component;
+
+@Component
+public class LocalCompatibilityIdentityProvider implements ExternalIdentityProvider {
+    @Override
+    public Optional<ExternalIdentity> authenticate(String username, String credential) {
+        if (username == null || username.isBlank() || credential == null || credential.isBlank()) {
+            return Optional.empty();
+        }
+        return Optional.of(new ExternalIdentity(username, username, "LOCAL"));
+    }
+}

+ 6 - 0
backend/src/main/java/com/example/hrlab/auth/LoginRequest.java

@@ -0,0 +1,6 @@
+package com.example.hrlab.auth;
+
+import jakarta.validation.constraints.NotBlank;
+
+public record LoginRequest(@NotBlank String username, @NotBlank String password, String deviceName) {
+}

+ 6 - 0
backend/src/main/java/com/example/hrlab/auth/LoginResult.java

@@ -0,0 +1,6 @@
+package com.example.hrlab.auth;
+
+import java.util.Map;
+
+public record LoginResult(String accessToken, String refreshToken, long expiresIn, Map<String, Object> user) {
+}

+ 4 - 0
backend/src/main/java/com/example/hrlab/auth/LogoutRequest.java

@@ -0,0 +1,4 @@
+package com.example.hrlab.auth;
+
+public record LogoutRequest(String refreshToken) {
+}

+ 6 - 0
backend/src/main/java/com/example/hrlab/auth/RefreshRequest.java

@@ -0,0 +1,6 @@
+package com.example.hrlab.auth;
+
+import jakarta.validation.constraints.NotBlank;
+
+public record RefreshRequest(@NotBlank String refreshToken, String deviceName) {
+}

+ 18 - 0
backend/src/main/java/com/example/hrlab/common/ApiResponse.java

@@ -0,0 +1,18 @@
+package com.example.hrlab.common;
+
+public record ApiResponse<T>(String code, String message, String correlationId, T data) {
+    public static <T> ApiResponse<T> ok(T data) {
+        return new ApiResponse<>("OK", "success", Correlation.currentId(), data);
+    }
+
+    public static <T> ApiResponse<T> accepted(T data) {
+        return new ApiResponse<>("ACCEPTED", "accepted", Correlation.currentId(), data);
+    }
+
+    public static ApiResponse<ErrorBody> error(String code, String message, Object details) {
+        return new ApiResponse<>(code, message, Correlation.currentId(), new ErrorBody(details));
+    }
+
+    public record ErrorBody(Object details) {
+    }
+}

+ 20 - 0
backend/src/main/java/com/example/hrlab/common/AsyncTaskPublisher.java

@@ -0,0 +1,20 @@
+package com.example.hrlab.common;
+
+import com.example.hrlab.config.AppProperties;
+import org.springframework.amqp.rabbit.core.RabbitTemplate;
+import org.springframework.stereotype.Service;
+
+@Service
+public class AsyncTaskPublisher {
+    private final RabbitTemplate rabbitTemplate;
+    private final AppProperties properties;
+
+    public AsyncTaskPublisher(RabbitTemplate rabbitTemplate, AppProperties properties) {
+        this.rabbitTemplate = rabbitTemplate;
+        this.properties = properties;
+    }
+
+    public void publish(String routingKey, String payload) {
+        rabbitTemplate.convertAndSend(properties.tasks().exchange(), routingKey, payload);
+    }
+}

+ 36 - 0
backend/src/main/java/com/example/hrlab/common/BusinessException.java

@@ -0,0 +1,36 @@
+package com.example.hrlab.common;
+
+import org.springframework.http.HttpStatus;
+
+public class BusinessException extends RuntimeException {
+    private final String code;
+    private final HttpStatus status;
+    private final Object details;
+
+    public BusinessException(String code, String message) {
+        this(code, message, HttpStatus.BAD_REQUEST, null);
+    }
+
+    public BusinessException(String code, String message, HttpStatus status) {
+        this(code, message, status, null);
+    }
+
+    public BusinessException(String code, String message, HttpStatus status, Object details) {
+        super(message);
+        this.code = code;
+        this.status = status;
+        this.details = details;
+    }
+
+    public String code() {
+        return code;
+    }
+
+    public HttpStatus status() {
+        return status;
+    }
+
+    public Object details() {
+        return details;
+    }
+}

+ 21 - 0
backend/src/main/java/com/example/hrlab/common/Correlation.java

@@ -0,0 +1,21 @@
+package com.example.hrlab.common;
+
+import java.util.Optional;
+import java.util.UUID;
+import org.slf4j.MDC;
+
+public final class Correlation {
+    public static final String HEADER = "X-Correlation-Id";
+    public static final String MDC_KEY = "correlationId";
+
+    private Correlation() {
+    }
+
+    public static String currentId() {
+        return Optional.ofNullable(MDC.get(MDC_KEY)).orElseGet(() -> {
+            String id = UUID.randomUUID().toString();
+            MDC.put(MDC_KEY, id);
+            return id;
+        });
+    }
+}

+ 29 - 0
backend/src/main/java/com/example/hrlab/common/CorrelationFilter.java

@@ -0,0 +1,29 @@
+package com.example.hrlab.common;
+
+import jakarta.servlet.FilterChain;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import java.io.IOException;
+import java.util.UUID;
+import org.slf4j.MDC;
+import org.springframework.stereotype.Component;
+import org.springframework.web.filter.OncePerRequestFilter;
+
+@Component
+public class CorrelationFilter extends OncePerRequestFilter {
+    @Override
+    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
+            throws ServletException, IOException {
+        String incoming = request.getHeader(Correlation.HEADER);
+        String id = incoming == null || incoming.isBlank() ? UUID.randomUUID().toString() : incoming;
+        MDC.put(Correlation.MDC_KEY, id);
+        response.setHeader(Correlation.HEADER, id);
+        try {
+            filterChain.doFilter(request, response);
+        } finally {
+            MDC.remove(Correlation.MDC_KEY);
+            MDC.remove("userId");
+        }
+    }
+}

+ 16 - 0
backend/src/main/java/com/example/hrlab/common/ErrorCodes.java

@@ -0,0 +1,16 @@
+package com.example.hrlab.common;
+
+public final class ErrorCodes {
+    public static final String VALIDATION_FAILED = "VALIDATION_FAILED";
+    public static final String UNAUTHORIZED = "UNAUTHORIZED";
+    public static final String FORBIDDEN = "FORBIDDEN";
+    public static final String NOT_FOUND = "NOT_FOUND";
+    public static final String CONFLICT = "CONFLICT";
+    public static final String BUSINESS_RULE_VIOLATION = "BUSINESS_RULE_VIOLATION";
+    public static final String FILE_REJECTED = "FILE_REJECTED";
+    public static final String IDEMPOTENCY_CONFLICT = "IDEMPOTENCY_CONFLICT";
+    public static final String INTERNAL_ERROR = "INTERNAL_ERROR";
+
+    private ErrorCodes() {
+    }
+}

+ 68 - 0
backend/src/main/java/com/example/hrlab/common/GlobalExceptionHandler.java

@@ -0,0 +1,68 @@
+package com.example.hrlab.common;
+
+import jakarta.validation.ConstraintViolationException;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import org.springframework.dao.DataIntegrityViolationException;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.access.AccessDeniedException;
+import org.springframework.security.core.AuthenticationException;
+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;
+import org.springframework.web.multipart.MaxUploadSizeExceededException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+@RestControllerAdvice
+public class GlobalExceptionHandler {
+    private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
+
+    @ExceptionHandler(BusinessException.class)
+    ResponseEntity<ApiResponse<ApiResponse.ErrorBody>> business(BusinessException ex) {
+        return ResponseEntity.status(ex.status()).body(ApiResponse.error(ex.code(), ex.getMessage(), ex.details()));
+    }
+
+    @ExceptionHandler(MethodArgumentNotValidException.class)
+    ResponseEntity<ApiResponse<ApiResponse.ErrorBody>> validation(MethodArgumentNotValidException ex) {
+        Map<String, String> details = new LinkedHashMap<>();
+        for (FieldError error : ex.getBindingResult().getFieldErrors()) {
+            details.put(error.getField(), error.getDefaultMessage());
+        }
+        return ResponseEntity.badRequest().body(ApiResponse.error(ErrorCodes.VALIDATION_FAILED, "输入参数不合法", details));
+    }
+
+    @ExceptionHandler(ConstraintViolationException.class)
+    ResponseEntity<ApiResponse<ApiResponse.ErrorBody>> constraint(ConstraintViolationException ex) {
+        return ResponseEntity.badRequest().body(ApiResponse.error(ErrorCodes.VALIDATION_FAILED, "输入参数不合法", ex.getMessage()));
+    }
+
+    @ExceptionHandler(DataIntegrityViolationException.class)
+    ResponseEntity<ApiResponse<ApiResponse.ErrorBody>> dataIntegrity(DataIntegrityViolationException ex) {
+        return ResponseEntity.status(HttpStatus.CONFLICT).body(ApiResponse.error(ErrorCodes.CONFLICT, "数据约束冲突", null));
+    }
+
+    @ExceptionHandler({AccessDeniedException.class})
+    ResponseEntity<ApiResponse<ApiResponse.ErrorBody>> denied(Exception ex) {
+        return ResponseEntity.status(HttpStatus.FORBIDDEN).body(ApiResponse.error(ErrorCodes.FORBIDDEN, "没有访问权限", null));
+    }
+
+    @ExceptionHandler({AuthenticationException.class})
+    ResponseEntity<ApiResponse<ApiResponse.ErrorBody>> auth(Exception ex) {
+        return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(ApiResponse.error(ErrorCodes.UNAUTHORIZED, "认证失败", null));
+    }
+
+    @ExceptionHandler(MaxUploadSizeExceededException.class)
+    ResponseEntity<ApiResponse<ApiResponse.ErrorBody>> upload(MaxUploadSizeExceededException ex) {
+        return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(ApiResponse.error(ErrorCodes.FILE_REJECTED, "上传文件超过限制", null));
+    }
+
+    @ExceptionHandler(Exception.class)
+    ResponseEntity<ApiResponse<ApiResponse.ErrorBody>> generic(Exception ex) {
+        log.error("Unhandled request exception", ex);
+        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
+                .body(ApiResponse.error(ErrorCodes.INTERNAL_ERROR, "系统处理失败", null));
+    }
+}

+ 79 - 0
backend/src/main/java/com/example/hrlab/common/JobMonitorController.java

@@ -0,0 +1,79 @@
+package com.example.hrlab.common;
+
+import java.util.Map;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.security.access.prepost.PreAuthorize;
+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.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+@RequestMapping("/api/v1")
+public class JobMonitorController {
+    private final JdbcTemplate jdbcTemplate;
+    private final AsyncTaskPublisher taskPublisher;
+
+    public JobMonitorController(JdbcTemplate jdbcTemplate, AsyncTaskPublisher taskPublisher) {
+        this.jdbcTemplate = jdbcTemplate;
+        this.taskPublisher = taskPublisher;
+    }
+
+    @GetMapping("/import-jobs")
+    @PreAuthorize("hasAnyRole('SYSTEM_ADMIN','HR_ADMIN','AUDITOR')")
+    public ApiResponse<PageResponse<Map<String, Object>>> importJobs(@RequestParam(defaultValue = "1") int page,
+                                                                     @RequestParam(defaultValue = "20") int size) {
+        int safePage = Math.max(page, 1);
+        int safeSize = Math.min(Math.max(size, 1), 100);
+        long total = jdbcTemplate.queryForObject("SELECT count(*) FROM import_jobs", Long.class);
+        var rows = jdbcTemplate.query("SELECT * FROM import_jobs ORDER BY id DESC LIMIT ? OFFSET ?",
+                (rs, rowNum) -> Rows.map(rs), safeSize, (safePage - 1) * safeSize);
+        return ApiResponse.ok(new PageResponse<>(rows, safePage, safeSize, total));
+    }
+
+    @GetMapping("/import-jobs/{id}")
+    @PreAuthorize("hasAnyRole('SYSTEM_ADMIN','HR_ADMIN','AUDITOR')")
+    public ApiResponse<Map<String, Object>> importJob(@PathVariable Long id) {
+        return ApiResponse.ok(find("import_jobs", id));
+    }
+
+    @GetMapping("/integration/events")
+    @PreAuthorize("hasAnyRole('SYSTEM_ADMIN','AUDITOR','INTEGRATION_CLIENT')")
+    public ApiResponse<PageResponse<Map<String, Object>>> integrationEvents(@RequestParam(defaultValue = "1") int page,
+                                                                            @RequestParam(defaultValue = "20") int size) {
+        int safePage = Math.max(page, 1);
+        int safeSize = Math.min(Math.max(size, 1), 100);
+        long total = jdbcTemplate.queryForObject("SELECT count(*) FROM integration_events", Long.class);
+        var rows = jdbcTemplate.query("SELECT * FROM integration_events ORDER BY id DESC LIMIT ? OFFSET ?",
+                (rs, rowNum) -> Rows.map(rs), safeSize, (safePage - 1) * safeSize);
+        return ApiResponse.ok(new PageResponse<>(rows, safePage, safeSize, total));
+    }
+
+    @PostMapping("/integration/events/{id}/retry")
+    @PreAuthorize("hasAnyRole('SYSTEM_ADMIN','INTEGRATION_CLIENT')")
+    public ApiResponse<Map<String, Object>> retryIntegrationEvent(@PathVariable Long id) {
+        jdbcTemplate.update("UPDATE integration_events SET status = 'PENDING', next_retry_at = now(), updated_at = now() WHERE id = ?", id);
+        taskPublisher.publish("webhook", String.valueOf(id));
+        return ApiResponse.accepted(find("integration_events", id));
+    }
+
+    @GetMapping("/tasks/dead-letter")
+    @PreAuthorize("hasAnyRole('SYSTEM_ADMIN','AUDITOR')")
+    public ApiResponse<Map<String, Object>> deadLetterSummary() {
+        long failedExports = jdbcTemplate.queryForObject("SELECT count(*) FROM export_jobs WHERE status = 'FAILED'", Long.class);
+        long failedImports = jdbcTemplate.queryForObject("SELECT count(*) FROM import_jobs WHERE status = 'FAILED'", Long.class);
+        long failedEvents = jdbcTemplate.queryForObject("SELECT count(*) FROM integration_events WHERE status = 'FAILED'", Long.class);
+        return ApiResponse.ok(Map.of("failedExports", failedExports, "failedImports", failedImports, "failedIntegrationEvents", failedEvents));
+    }
+
+    private Map<String, Object> find(String table, Long id) {
+        return jdbcTemplate.query("SELECT * FROM " + table + " WHERE id = ?", rs -> {
+            if (!rs.next()) {
+                throw new BusinessException(ErrorCodes.NOT_FOUND, table + " not found", org.springframework.http.HttpStatus.NOT_FOUND);
+            }
+            return Rows.map(rs);
+        }, id);
+    }
+}

+ 32 - 0
backend/src/main/java/com/example/hrlab/common/JsonUtils.java

@@ -0,0 +1,32 @@
+package com.example.hrlab.common;
+
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import java.util.Map;
+
+public final class JsonUtils {
+    private static final ObjectMapper MAPPER = new ObjectMapper().findAndRegisterModules();
+    private static final TypeReference<Map<String, Object>> MAP_TYPE = new TypeReference<>() {};
+
+    private JsonUtils() {
+    }
+
+    public static String toJson(Object value) {
+        try {
+            return MAPPER.writeValueAsString(value == null ? Map.of() : value);
+        } catch (Exception ex) {
+            throw new IllegalArgumentException("JSON序列化失败", ex);
+        }
+    }
+
+    public static Map<String, Object> toMap(String json) {
+        try {
+            if (json == null || json.isBlank()) {
+                return Map.of();
+            }
+            return MAPPER.readValue(json, MAP_TYPE);
+        } catch (Exception ex) {
+            throw new IllegalArgumentException("JSON解析失败", ex);
+        }
+    }
+}

+ 6 - 0
backend/src/main/java/com/example/hrlab/common/PageResponse.java

@@ -0,0 +1,6 @@
+package com.example.hrlab.common;
+
+import java.util.List;
+
+public record PageResponse<T>(List<T> items, int page, int size, long total) {
+}

+ 26 - 0
backend/src/main/java/com/example/hrlab/common/Rows.java

@@ -0,0 +1,26 @@
+package com.example.hrlab.common;
+
+import java.sql.ResultSet;
+import java.sql.ResultSetMetaData;
+import java.sql.SQLException;
+import java.time.OffsetDateTime;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+public final class Rows {
+    private Rows() {
+    }
+
+    public static Map<String, Object> map(ResultSet rs) throws SQLException {
+        ResultSetMetaData meta = rs.getMetaData();
+        Map<String, Object> row = new LinkedHashMap<>();
+        for (int i = 1; i <= meta.getColumnCount(); i++) {
+            Object value = rs.getObject(i);
+            if (value instanceof OffsetDateTime dateTime) {
+                value = dateTime.toString();
+            }
+            row.put(meta.getColumnLabel(i), value);
+        }
+        return row;
+    }
+}

+ 37 - 0
backend/src/main/java/com/example/hrlab/common/TaskWorker.java

@@ -0,0 +1,37 @@
+package com.example.hrlab.common;
+
+import com.example.hrlab.payroll.PayrollService;
+import com.example.hrlab.reporting.ExportService;
+import java.util.List;
+import org.springframework.amqp.rabbit.annotation.RabbitListener;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.stereotype.Component;
+
+@Component
+public class TaskWorker {
+    private final PayrollService payrollService;
+    private final ExportService exportService;
+    private final JdbcTemplate jdbcTemplate;
+
+    public TaskWorker(PayrollService payrollService, ExportService exportService, JdbcTemplate jdbcTemplate) {
+        this.payrollService = payrollService;
+        this.exportService = exportService;
+        this.jdbcTemplate = jdbcTemplate;
+    }
+
+    @RabbitListener(queues = "${app.tasks.payroll-queue}")
+    public void payroll(String runId) {
+        payrollService.processRun(Long.valueOf(runId), List.of());
+    }
+
+    @RabbitListener(queues = "${app.tasks.export-queue}")
+    public void export(String jobId) {
+        exportService.process(Long.valueOf(jobId));
+    }
+
+    @RabbitListener(queues = "${app.tasks.webhook-queue}")
+    public void webhook(String eventId) {
+        jdbcTemplate.update("UPDATE integration_events SET status = 'COMPLETED', retry_count = retry_count + 1, updated_at = now() WHERE id = ?",
+                Long.valueOf(eventId));
+    }
+}

+ 47 - 0
backend/src/main/java/com/example/hrlab/config/AppProperties.java

@@ -0,0 +1,47 @@
+package com.example.hrlab.config;
+
+import java.time.Duration;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+
+@ConfigurationProperties(prefix = "app")
+public record AppProperties(
+        String name,
+        String version,
+        Jwt jwt,
+        Security security,
+        Tasks tasks,
+        Storage storage,
+        Lab lab) {
+
+    public record Jwt(String issuer, String secret, long accessTokenMinutes, long refreshTokenDays) {
+        public Duration accessDuration() {
+            return Duration.ofMinutes(accessTokenMinutes);
+        }
+
+        public Duration refreshDuration() {
+            return Duration.ofDays(refreshTokenDays);
+        }
+    }
+
+    public record Security(int loginFailureThreshold, long lockMinutes, String webhookSecret) {
+        public Duration lockDuration() {
+            return Duration.ofMinutes(lockMinutes);
+        }
+    }
+
+    public record Tasks(boolean inline, String exchange, String payrollQueue, String exportQueue, String importQueue, String webhookQueue) {
+    }
+
+    public record Storage(String exportDir, String importDir) {
+    }
+
+    public record Lab(boolean enabled, String fixedClock, Seed seed, Failpoint failpoint) {
+    }
+
+    public record Seed(boolean enabled, long randomSeed, int employeeCount, int candidateCount, int attendanceDays) {
+    }
+
+    public record Failpoint(boolean enabled, long exportDelayMs, long payrollExtraDelayMs, boolean webhookForce5xx,
+                            boolean mqRequeueOnce, boolean formulaException, boolean cacheDisabled) {
+    }
+}

+ 18 - 0
backend/src/main/java/com/example/hrlab/config/ClockConfig.java

@@ -0,0 +1,18 @@
+package com.example.hrlab.config;
+
+import java.time.Clock;
+import java.time.OffsetDateTime;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+@Configuration
+public class ClockConfig {
+    @Bean
+    Clock businessClock(AppProperties properties) {
+        if (properties.lab() != null && properties.lab().enabled() && properties.lab().fixedClock() != null) {
+            OffsetDateTime fixed = OffsetDateTime.parse(properties.lab().fixedClock());
+            return Clock.fixed(fixed.toInstant(), fixed.getOffset());
+        }
+        return Clock.systemDefaultZone();
+    }
+}

+ 19 - 0
backend/src/main/java/com/example/hrlab/config/PasswordConfig.java

@@ -0,0 +1,19 @@
+package com.example.hrlab.config;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.security.crypto.argon2.Argon2PasswordEncoder;
+import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
+import org.springframework.security.crypto.password.DelegatingPasswordEncoder;
+import org.springframework.security.crypto.password.PasswordEncoder;
+import java.util.Map;
+
+@Configuration
+public class PasswordConfig {
+    @Bean
+    PasswordEncoder passwordEncoder() {
+        PasswordEncoder argon2 = Argon2PasswordEncoder.defaultsForSpringSecurity_v5_8();
+        PasswordEncoder bcrypt = new BCryptPasswordEncoder();
+        return new DelegatingPasswordEncoder("argon2id", Map.of("argon2id", argon2, "bcrypt", bcrypt));
+    }
+}

+ 79 - 0
backend/src/main/java/com/example/hrlab/config/RabbitConfig.java

@@ -0,0 +1,79 @@
+package com.example.hrlab.config;
+
+import org.springframework.amqp.core.Binding;
+import org.springframework.amqp.core.BindingBuilder;
+import org.springframework.amqp.core.DirectExchange;
+import org.springframework.amqp.core.Queue;
+import org.springframework.amqp.core.QueueBuilder;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+@Configuration
+public class RabbitConfig {
+    @Bean
+    DirectExchange taskExchange(AppProperties properties) {
+        return new DirectExchange(properties.tasks().exchange(), true, false);
+    }
+
+    @Bean
+    DirectExchange deadLetterExchange(AppProperties properties) {
+        return new DirectExchange(properties.tasks().exchange() + ".dlx", true, false);
+    }
+
+    @Bean
+    Queue deadLetterQueue(AppProperties properties) {
+        return QueueBuilder.durable(properties.tasks().exchange() + ".dead").build();
+    }
+
+    @Bean
+    Binding bindDeadLetter(DirectExchange deadLetterExchange, Queue deadLetterQueue) {
+        return BindingBuilder.bind(deadLetterQueue).to(deadLetterExchange).with("dead");
+    }
+
+    @Bean
+    Queue payrollQueue(AppProperties properties) {
+        return durableTaskQueue(properties.tasks().payrollQueue(), properties);
+    }
+
+    @Bean
+    Queue exportQueue(AppProperties properties) {
+        return durableTaskQueue(properties.tasks().exportQueue(), properties);
+    }
+
+    @Bean
+    Queue importQueue(AppProperties properties) {
+        return durableTaskQueue(properties.tasks().importQueue(), properties);
+    }
+
+    @Bean
+    Queue webhookQueue(AppProperties properties) {
+        return durableTaskQueue(properties.tasks().webhookQueue(), properties);
+    }
+
+    @Bean
+    Binding bindPayroll(DirectExchange taskExchange, Queue payrollQueue) {
+        return BindingBuilder.bind(payrollQueue).to(taskExchange).with("payroll");
+    }
+
+    @Bean
+    Binding bindExport(DirectExchange taskExchange, Queue exportQueue) {
+        return BindingBuilder.bind(exportQueue).to(taskExchange).with("export");
+    }
+
+    @Bean
+    Binding bindImport(DirectExchange taskExchange, Queue importQueue) {
+        return BindingBuilder.bind(importQueue).to(taskExchange).with("import");
+    }
+
+    @Bean
+    Binding bindWebhook(DirectExchange taskExchange, Queue webhookQueue) {
+        return BindingBuilder.bind(webhookQueue).to(taskExchange).with("webhook");
+    }
+
+    private Queue durableTaskQueue(String name, AppProperties properties) {
+        return QueueBuilder.durable(name)
+                .withArgument("x-dead-letter-exchange", properties.tasks().exchange() + ".dlx")
+                .withArgument("x-dead-letter-routing-key", "dead")
+                .build();
+    }
+}

+ 7 - 0
backend/src/main/java/com/example/hrlab/employee/AttachmentRequest.java

@@ -0,0 +1,7 @@
+package com.example.hrlab.employee;
+
+import jakarta.validation.constraints.Min;
+import jakarta.validation.constraints.NotBlank;
+
+public record AttachmentRequest(@NotBlank String fileName, @NotBlank String fileType, @Min(1) long fileSize) {
+}

+ 204 - 0
backend/src/main/java/com/example/hrlab/employee/EmployeeController.java

@@ -0,0 +1,204 @@
+package com.example.hrlab.employee;
+
+import com.example.hrlab.audit.AuditService;
+import com.example.hrlab.common.ApiResponse;
+import com.example.hrlab.common.BusinessException;
+import com.example.hrlab.common.ErrorCodes;
+import com.example.hrlab.common.JsonUtils;
+import com.example.hrlab.common.PageResponse;
+import com.example.hrlab.common.Rows;
+import com.example.hrlab.security.DataScopeService;
+import com.example.hrlab.security.SecurityUtils;
+import com.example.hrlab.security.SensitiveDataService;
+import jakarta.validation.Valid;
+import java.util.Map;
+import org.springframework.http.HttpStatus;
+import org.springframework.jdbc.core.JdbcTemplate;
+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;
+
+@RestController
+@RequestMapping("/api/v1/employees")
+public class EmployeeController {
+    private final JdbcTemplate jdbcTemplate;
+    private final SensitiveDataService sensitiveDataService;
+    private final DataScopeService dataScopeService;
+    private final AuditService auditService;
+
+    public EmployeeController(JdbcTemplate jdbcTemplate, SensitiveDataService sensitiveDataService,
+                              DataScopeService dataScopeService, AuditService auditService) {
+        this.jdbcTemplate = jdbcTemplate;
+        this.sensitiveDataService = sensitiveDataService;
+        this.dataScopeService = dataScopeService;
+        this.auditService = auditService;
+    }
+
+    @GetMapping
+    @PreAuthorize("isAuthenticated()")
+    public ApiResponse<PageResponse<Map<String, Object>>> list(@RequestParam(defaultValue = "1") int page,
+                                                               @RequestParam(defaultValue = "20") int size,
+                                                               @RequestParam(required = false) String keyword) {
+        int safePage = Math.max(page, 1);
+        int safeSize = Math.min(Math.max(size, 1), 100);
+        String scope = dataScopeService.employeeScopeSql("e");
+        String keywordSql = keyword == null || keyword.isBlank() ? "" : " AND (e.employee_no ILIKE ? OR e.display_name ILIKE ?) ";
+        Object[] params = keyword == null || keyword.isBlank()
+                ? new Object[]{safeSize, (safePage - 1) * safeSize}
+                : new Object[]{"%" + keyword + "%", "%" + keyword + "%", safeSize, (safePage - 1) * safeSize};
+        long total = keyword == null || keyword.isBlank()
+                ? jdbcTemplate.queryForObject("SELECT count(*) FROM employees e WHERE e.archived = false " + scope, Long.class)
+                : jdbcTemplate.queryForObject("SELECT count(*) FROM employees e WHERE e.archived = false " + scope + keywordSql,
+                        Long.class, "%" + keyword + "%", "%" + keyword + "%");
+        var rows = jdbcTemplate.query("""
+                SELECT e.*, d.dept_name, p.position_name
+                FROM employees e
+                JOIN departments d ON d.id = e.department_id
+                JOIN positions p ON p.id = e.position_id
+                WHERE e.archived = false
+                """ + scope + keywordSql + " ORDER BY e.employee_no LIMIT ? OFFSET ?",
+                (rs, rowNum) -> mask(Rows.map(rs)), params);
+        return ApiResponse.ok(new PageResponse<>(rows, safePage, safeSize, total));
+    }
+
+    @GetMapping("/{id}")
+    @PreAuthorize("@dataScope.canAccessEmployee(#id)")
+    public ApiResponse<Map<String, Object>> get(@PathVariable Long id) {
+        return ApiResponse.ok(findEmployee(id));
+    }
+
+    @PostMapping
+    @PreAuthorize("hasAnyRole('SYSTEM_ADMIN','HR_ADMIN')")
+    public ApiResponse<Map<String, Object>> create(@Valid @RequestBody EmployeeRequest request) {
+        validateContract(request);
+        Long id = jdbcTemplate.queryForObject("""
+                INSERT INTO employees(employee_no, department_id, position_id, display_name, employment_status,
+                    hire_date, leave_date, email_cipher, mobile_cipher, id_no_cipher, bank_card_cipher,
+                    emergency_contact_jsonb, contract_start_date, contract_end_date, tags, ext_jsonb)
+                VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?::jsonb, ?, ?, ?::jsonb, ?::jsonb)
+                RETURNING id
+                """, Long.class, request.employeeNo(), request.departmentId(), request.positionId(), request.displayName(),
+                defaultString(request.employmentStatus(), "ACTIVE"), request.hireDate(), request.leaveDate(),
+                sensitiveDataService.encrypt(request.email()), sensitiveDataService.encrypt(request.mobile()),
+                sensitiveDataService.encrypt(request.idNo()), sensitiveDataService.encrypt(request.bankCard()),
+                JsonUtils.toJson(defaultMap(request.emergencyContact())), request.contractStartDate(), request.contractEndDate(),
+                JsonUtils.toJson(request.tags() == null ? java.util.List.of() : request.tags()), JsonUtils.toJson(defaultMap(request.ext())));
+        auditService.record("EMPLOYEE_CREATE", "employees", id, null, request);
+        return ApiResponse.ok(findEmployee(id));
+    }
+
+    @PutMapping("/{id}")
+    @PreAuthorize("hasAnyRole('SYSTEM_ADMIN','HR_ADMIN')")
+    public ApiResponse<Map<String, Object>> update(@PathVariable Long id, @Valid @RequestBody EmployeeRequest request) {
+        validateContract(request);
+        Map<String, Object> before = findEmployee(id);
+        jdbcTemplate.update("""
+                UPDATE employees SET employee_no = ?, department_id = ?, position_id = ?, display_name = ?, employment_status = ?,
+                    hire_date = ?, leave_date = ?,
+                    email_cipher = COALESCE(?, email_cipher),
+                    mobile_cipher = COALESCE(?, mobile_cipher),
+                    id_no_cipher = COALESCE(?, id_no_cipher),
+                    bank_card_cipher = COALESCE(?, bank_card_cipher),
+                    emergency_contact_jsonb = ?::jsonb, contract_start_date = ?, contract_end_date = ?, tags = ?::jsonb,
+                    ext_jsonb = ?::jsonb, version = version + 1, updated_at = now()
+                WHERE id = ?
+                """, request.employeeNo(), request.departmentId(), request.positionId(), request.displayName(),
+                defaultString(request.employmentStatus(), "ACTIVE"), request.hireDate(), request.leaveDate(),
+                sensitiveDataService.encrypt(request.email()), sensitiveDataService.encrypt(request.mobile()),
+                sensitiveDataService.encrypt(request.idNo()), sensitiveDataService.encrypt(request.bankCard()),
+                JsonUtils.toJson(defaultMap(request.emergencyContact())), request.contractStartDate(), request.contractEndDate(),
+                JsonUtils.toJson(request.tags() == null ? java.util.List.of() : request.tags()), JsonUtils.toJson(defaultMap(request.ext())), id);
+        auditService.record("EMPLOYEE_UPDATE", "employees", id, before, request);
+        return ApiResponse.ok(findEmployee(id));
+    }
+
+    @DeleteMapping("/{id}")
+    @PreAuthorize("hasAnyRole('SYSTEM_ADMIN','HR_ADMIN')")
+    public ApiResponse<Map<String, Object>> archive(@PathVariable Long id) {
+        Map<String, Object> before = findEmployee(id);
+        jdbcTemplate.update("UPDATE employees SET archived = true, employment_status = 'ARCHIVED', version = version + 1, updated_at = now() WHERE id = ?", id);
+        auditService.record("EMPLOYEE_ARCHIVE", "employees", id, before, Map.of("archived", true));
+        return ApiResponse.ok(findEmployee(id));
+    }
+
+    @PostMapping("/{id}/transfer")
+    @PreAuthorize("hasAnyRole('SYSTEM_ADMIN','HR_ADMIN')")
+    public ApiResponse<Map<String, Object>> transfer(@PathVariable Long id, @Valid @RequestBody TransferRequest request) {
+        Map<String, Object> before = findEmployee(id);
+        jdbcTemplate.update("""
+                UPDATE employees SET department_id = ?, position_id = ?, version = version + 1, updated_at = now()
+                WHERE id = ?
+                """, request.departmentId(), request.positionId(), id);
+        jdbcTemplate.update("""
+                INSERT INTO employee_change_history(employee_id, change_type, before_json, after_json, changed_by)
+                VALUES (?, 'TRANSFER', ?::jsonb, ?::jsonb, ?)
+                """, id, JsonUtils.toJson(before), JsonUtils.toJson(request), SecurityUtils.currentUserIdOrNull());
+        auditService.record("EMPLOYEE_TRANSFER", "employees", id, before, request);
+        return ApiResponse.ok(findEmployee(id));
+    }
+
+    @GetMapping("/{id}/changes")
+    @PreAuthorize("@dataScope.canAccessEmployee(#id)")
+    public ApiResponse<Object> changes(@PathVariable Long id) {
+        return ApiResponse.ok(jdbcTemplate.queryForList("""
+                SELECT * FROM employee_change_history WHERE employee_id = ? ORDER BY changed_at DESC
+                """, id));
+    }
+
+    @PostMapping("/{id}/attachments")
+    @PreAuthorize("hasAnyRole('SYSTEM_ADMIN','HR_ADMIN')")
+    public ApiResponse<Map<String, Object>> addAttachment(@PathVariable Long id, @Valid @RequestBody AttachmentRequest request) {
+        Long attachmentId = jdbcTemplate.queryForObject("""
+                INSERT INTO employee_attachments(employee_id, file_name, file_type, file_size, uploaded_by)
+                VALUES (?, ?, ?, ?, ?) RETURNING id
+                """, Long.class, id, request.fileName(), request.fileType(), request.fileSize(), SecurityUtils.currentUserIdOrNull());
+        auditService.record("EMPLOYEE_ATTACHMENT_ADD", "employee_attachments", attachmentId, null, request);
+        return ApiResponse.ok(jdbcTemplate.query("SELECT * FROM employee_attachments WHERE id = ?",
+                rs -> { rs.next(); return Rows.map(rs); }, attachmentId));
+    }
+
+    private Map<String, Object> findEmployee(Long id) {
+        return jdbcTemplate.query("""
+                SELECT e.*, d.dept_name, p.position_name
+                FROM employees e
+                JOIN departments d ON d.id = e.department_id
+                JOIN positions p ON p.id = e.position_id
+                WHERE e.id = ?
+                """, rs -> {
+            if (!rs.next()) {
+                throw new BusinessException(ErrorCodes.NOT_FOUND, "员工不存在", HttpStatus.NOT_FOUND);
+            }
+            return mask(Rows.map(rs));
+        }, id);
+    }
+
+    private Map<String, Object> mask(Map<String, Object> row) {
+        row.computeIfPresent("email_cipher", (k, v) -> sensitiveDataService.mask(String.valueOf(v)));
+        row.computeIfPresent("mobile_cipher", (k, v) -> sensitiveDataService.mask(String.valueOf(v)));
+        row.computeIfPresent("id_no_cipher", (k, v) -> sensitiveDataService.mask(String.valueOf(v)));
+        row.computeIfPresent("bank_card_cipher", (k, v) -> sensitiveDataService.mask(String.valueOf(v)));
+        return row;
+    }
+
+    private void validateContract(EmployeeRequest request) {
+        if (request.contractStartDate() != null && request.contractEndDate() != null
+                && request.contractStartDate().isAfter(request.contractEndDate())) {
+            throw new BusinessException(ErrorCodes.BUSINESS_RULE_VIOLATION, "合同开始日期不能晚于结束日期");
+        }
+    }
+
+    private Map<String, Object> defaultMap(Map<String, Object> map) {
+        return map == null ? Map.of() : map;
+    }
+
+    private String defaultString(String value, String defaultValue) {
+        return value == null || value.isBlank() ? defaultValue : value;
+    }
+}

+ 25 - 0
backend/src/main/java/com/example/hrlab/employee/EmployeeRequest.java

@@ -0,0 +1,25 @@
+package com.example.hrlab.employee;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import java.time.LocalDate;
+import java.util.Map;
+
+public record EmployeeRequest(
+        @NotBlank String employeeNo,
+        @NotNull Long departmentId,
+        @NotNull Long positionId,
+        @NotBlank String displayName,
+        String employmentStatus,
+        LocalDate hireDate,
+        LocalDate leaveDate,
+        String email,
+        String mobile,
+        String idNo,
+        String bankCard,
+        LocalDate contractStartDate,
+        LocalDate contractEndDate,
+        Map<String, Object> emergencyContact,
+        Object tags,
+        Map<String, Object> ext) {
+}

+ 8 - 0
backend/src/main/java/com/example/hrlab/employee/TransferRequest.java

@@ -0,0 +1,8 @@
+package com.example.hrlab.employee;
+
+import jakarta.validation.constraints.NotNull;
+import java.time.LocalDate;
+
+public record TransferRequest(@NotNull Long departmentId, @NotNull Long positionId,
+                              LocalDate effectiveDate, String reason) {
+}

+ 68 - 0
backend/src/main/java/com/example/hrlab/integration/IntegrationController.java

@@ -0,0 +1,68 @@
+package com.example.hrlab.integration;
+
+import com.example.hrlab.audit.AuditService;
+import com.example.hrlab.common.ApiResponse;
+import com.example.hrlab.common.BusinessException;
+import com.example.hrlab.common.ErrorCodes;
+import com.example.hrlab.config.AppProperties;
+import com.example.hrlab.security.HmacService;
+import com.example.hrlab.security.IdempotencyService;
+import java.util.Map;
+import org.springframework.http.HttpStatus;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestHeader;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+@RequestMapping("/api/v1/integration")
+public class IntegrationController {
+    private final JdbcTemplate jdbcTemplate;
+    private final HmacService hmacService;
+    private final IdempotencyService idempotencyService;
+    private final AuditService auditService;
+    private final AppProperties properties;
+
+    public IntegrationController(JdbcTemplate jdbcTemplate, HmacService hmacService, IdempotencyService idempotencyService,
+                                 AuditService auditService, AppProperties properties) {
+        this.jdbcTemplate = jdbcTemplate;
+        this.hmacService = hmacService;
+        this.idempotencyService = idempotencyService;
+        this.auditService = auditService;
+        this.properties = properties;
+    }
+
+    @GetMapping("/employees")
+    @PreAuthorize("hasAnyRole('SYSTEM_ADMIN','INTEGRATION_CLIENT')")
+    public ApiResponse<Object> employeeStatus(@RequestParam String employeeNo) {
+        return ApiResponse.ok(jdbcTemplate.queryForList("""
+                SELECT employee_no, display_name, employment_status FROM employees WHERE employee_no = ?
+                """, employeeNo));
+    }
+
+    @PostMapping("/webhooks/test-receiver")
+    @PreAuthorize("hasAnyRole('SYSTEM_ADMIN','INTEGRATION_CLIENT')")
+    public ApiResponse<Map<String, Object>> receive(@RequestBody String payload,
+                                                    @RequestHeader("X-Signature") String signature,
+                                                    @RequestHeader(value = "Idempotency-Key", required = false) String idempotencyKey) {
+        if (properties.lab().failpoint().webhookForce5xx()) {
+            throw new BusinessException(ErrorCodes.INTERNAL_ERROR, "Webhook 故障注入已触发", HttpStatus.INTERNAL_SERVER_ERROR);
+        }
+        if (!hmacService.verify(payload, signature)) {
+            throw new BusinessException(ErrorCodes.FORBIDDEN, "签名校验失败", HttpStatus.FORBIDDEN);
+        }
+        idempotencyService.requireNew("webhook", idempotencyKey);
+        Long id = jdbcTemplate.queryForObject("""
+                INSERT INTO integration_events(event_type, business_key, payload_json, signature, status)
+                VALUES ('WEBHOOK_RECEIVED', ?, ?::jsonb, ?, 'COMPLETED') RETURNING id
+                """, Long.class, idempotencyKey == null ? java.util.UUID.randomUUID().toString() : idempotencyKey,
+                payload, signature);
+        auditService.record("WEBHOOK_RECEIVE", "integration_events", id, null, Map.of("received", true));
+        return ApiResponse.ok(Map.of("eventId", id, "status", "COMPLETED"));
+    }
+}

+ 33 - 0
backend/src/main/java/com/example/hrlab/integration/IntegrationEventService.java

@@ -0,0 +1,33 @@
+package com.example.hrlab.integration;
+
+import com.example.hrlab.common.JsonUtils;
+import com.example.hrlab.security.HmacService;
+import java.util.Map;
+import org.springframework.dao.DataIntegrityViolationException;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.stereotype.Service;
+
+@Service
+public class IntegrationEventService {
+    private final JdbcTemplate jdbcTemplate;
+    private final HmacService hmacService;
+
+    public IntegrationEventService(JdbcTemplate jdbcTemplate, HmacService hmacService) {
+        this.jdbcTemplate = jdbcTemplate;
+        this.hmacService = hmacService;
+    }
+
+    public Long createEvent(String eventType, String businessKey, Map<String, Object> payload) {
+        String json = JsonUtils.toJson(payload);
+        try {
+            return jdbcTemplate.queryForObject("""
+                    INSERT INTO integration_events(event_type, business_key, payload_json, signature, status)
+                    VALUES (?, ?, ?::jsonb, ?, 'PENDING') RETURNING id
+                    """, Long.class, eventType, businessKey, json, hmacService.sign(json));
+        } catch (DataIntegrityViolationException ex) {
+            return jdbcTemplate.queryForObject("""
+                    SELECT id FROM integration_events WHERE event_type = ? AND business_key = ?
+                    """, Long.class, eventType, businessKey);
+        }
+    }
+}

+ 301 - 0
backend/src/main/java/com/example/hrlab/lab/DemoDataSeeder.java

@@ -0,0 +1,301 @@
+package com.example.hrlab.lab;
+
+import com.example.hrlab.config.AppProperties;
+import com.example.hrlab.security.SensitiveDataService;
+import java.math.BigDecimal;
+import java.sql.Date;
+import java.sql.Time;
+import java.time.Clock;
+import java.time.LocalDate;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Random;
+import org.springframework.boot.ApplicationArguments;
+import org.springframework.boot.ApplicationRunner;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.security.crypto.password.PasswordEncoder;
+import org.springframework.stereotype.Component;
+
+@Component
+public class DemoDataSeeder implements ApplicationRunner {
+    private final JdbcTemplate jdbcTemplate;
+    private final PasswordEncoder passwordEncoder;
+    private final SensitiveDataService sensitiveDataService;
+    private final AppProperties properties;
+    private final Clock clock;
+
+    public DemoDataSeeder(JdbcTemplate jdbcTemplate, PasswordEncoder passwordEncoder,
+                          SensitiveDataService sensitiveDataService, AppProperties properties, Clock clock) {
+        this.jdbcTemplate = jdbcTemplate;
+        this.passwordEncoder = passwordEncoder;
+        this.sensitiveDataService = sensitiveDataService;
+        this.properties = properties;
+        this.clock = clock;
+    }
+
+    @Override
+    public void run(ApplicationArguments args) {
+        if (!properties.lab().enabled() || !properties.lab().seed().enabled()) {
+            return;
+        }
+        seedOrganization();
+        seedShifts();
+        seedEmployees();
+        seedAccounts();
+        seedRecruitment();
+        seedAttendance();
+        seedPayrollPeriods();
+        seedPerformance();
+    }
+
+    private void seedOrganization() {
+        if (count("departments") > 0) {
+            return;
+        }
+        List<String> names = List.of("根组织", "人力中心", "财务中心", "研发一部", "研发二部", "交付一部", "交付二部",
+                "运营中心", "质量中心", "行政中心", "数据中心");
+        for (int i = 0; i < names.size(); i++) {
+            Long parentId = i == 0 ? null : 1L;
+            Long id = jdbcTemplate.queryForObject("""
+                    INSERT INTO departments(parent_id, dept_code, dept_name, status, sort_no)
+                    VALUES (?, ?, ?, 'ACTIVE', ?) RETURNING id
+                    """, Long.class, parentId, "D%03d".formatted(i + 1), names.get(i), i);
+            String parentPath = parentId == null ? "" : jdbcTemplate.queryForObject("SELECT path FROM departments WHERE id = ?", String.class, parentId);
+            jdbcTemplate.update("UPDATE departments SET path = ? WHERE id = ?", parentPath + "/" + id, id);
+        }
+        for (int i = 1; i <= 20; i++) {
+            jdbcTemplate.update("""
+                    INSERT INTO positions(position_code, position_name, level_code, status)
+                    VALUES (?, ?, ?, 'ACTIVE')
+                    """, "P%03d".formatted(i), "岗位%02d".formatted(i), "L" + ((i % 5) + 1));
+        }
+    }
+
+    private void seedShifts() {
+        if (count("shifts") > 0) {
+            return;
+        }
+        jdbcTemplate.update("INSERT INTO shifts(shift_code, shift_name, start_time, end_time, cross_day) VALUES ('DAY', '日班', ?, ?, false)",
+                Time.valueOf("09:00:00"), Time.valueOf("18:00:00"));
+        jdbcTemplate.update("INSERT INTO shifts(shift_code, shift_name, start_time, end_time, cross_day) VALUES ('NIGHT', '夜班', ?, ?, true)",
+                Time.valueOf("22:00:00"), Time.valueOf("06:00:00"));
+    }
+
+    private void seedEmployees() {
+        int target = properties.lab().seed().employeeCount();
+        if (count("employees") >= target) {
+            return;
+        }
+        Random random = new Random(properties.lab().seed().randomSeed());
+        List<Long> departmentIds = jdbcTemplate.queryForList("SELECT id FROM departments WHERE parent_id IS NOT NULL ORDER BY id", Long.class);
+        List<Long> positionIds = jdbcTemplate.queryForList("SELECT id FROM positions ORDER BY id", Long.class);
+        List<Object[]> batch = new ArrayList<>();
+        LocalDate hireBase = LocalDate.now(clock).minusYears(3);
+        for (int i = 1; i <= target; i++) {
+            Long dept = departmentIds.get(random.nextInt(departmentIds.size()));
+            Long pos = positionIds.get(random.nextInt(positionIds.size()));
+            String no = "E%04d".formatted(i);
+            batch.add(new Object[]{
+                    no, dept, pos, "员工%04d".formatted(i), "ACTIVE", Date.valueOf(hireBase.plusDays(i % 900)),
+                    sensitiveDataService.encrypt("employee%04d@example.local".formatted(i)),
+                    sensitiveDataService.encrypt("139%08d".formatted(i)),
+                    sensitiveDataService.encrypt("ID%010d".formatted(i)),
+                    sensitiveDataService.encrypt("622200%010d".formatted(i)),
+                    Date.valueOf(hireBase.plusDays(i % 900)),
+                    Date.valueOf(hireBase.plusYears(3).plusDays(i % 900))
+            });
+            if (batch.size() >= 1000) {
+                insertEmployees(batch);
+                batch.clear();
+            }
+        }
+        insertEmployees(batch);
+    }
+
+    private void insertEmployees(List<Object[]> batch) {
+        if (batch.isEmpty()) {
+            return;
+        }
+        jdbcTemplate.batchUpdate("""
+                INSERT INTO employees(employee_no, department_id, position_id, display_name, employment_status, hire_date,
+                    email_cipher, mobile_cipher, id_no_cipher, bank_card_cipher, emergency_contact_jsonb,
+                    contract_start_date, contract_end_date, tags, ext_jsonb)
+                VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '{}'::jsonb, ?, ?, '[]'::jsonb, '{}'::jsonb)
+                ON CONFLICT (employee_no) DO NOTHING
+                """, batch);
+    }
+
+    private void seedAccounts() {
+        String password = passwordEncoder.encode("ChangeMe123!");
+        createAccount("sys_admin", password, employeeId("E0001"), "SYSTEM_ADMIN");
+        createAccount("hr_admin", password, employeeId("E0002"), "HR_ADMIN");
+        createAccount("recruiter_01", password, employeeId("E0003"), "RECRUITER");
+        createAccount("manager_01", password, employeeId("E0004"), "DEPT_MANAGER");
+        createAccount("payroll_admin", password, employeeId("E0005"), "PAYROLL_ADMIN");
+        createAccount("employee_0001", password, employeeId("E0001"), "EMPLOYEE_SELF");
+        createAccount("auditor_01", password, employeeId("E0006"), "AUDITOR");
+        createAccount("integration_client", password, null, "INTEGRATION_CLIENT");
+    }
+
+    private void createAccount(String username, String password, Long employeeId, String roleCode) {
+        Integer existing = jdbcTemplate.queryForObject("SELECT count(*) FROM user_accounts WHERE username = ?", Integer.class, username);
+        if (existing != null && existing > 0) {
+            return;
+        }
+        Long userId = jdbcTemplate.queryForObject("""
+                INSERT INTO user_accounts(employee_id, username, password_hash, status)
+                VALUES (?, ?, ?, 'ACTIVE') RETURNING id
+                """, Long.class, employeeId, username, password);
+        jdbcTemplate.update("""
+                INSERT INTO user_roles(user_account_id, role_id)
+                SELECT ?, id FROM roles WHERE role_code = ?
+                """, userId, roleCode);
+    }
+
+    private void seedRecruitment() {
+        if (count("job_requisitions") == 0) {
+            List<Long> departments = jdbcTemplate.queryForList("SELECT id FROM departments WHERE parent_id IS NOT NULL ORDER BY id LIMIT 10", Long.class);
+            List<Long> positions = jdbcTemplate.queryForList("SELECT id FROM positions ORDER BY id LIMIT 10", Long.class);
+            for (int i = 0; i < departments.size(); i++) {
+                jdbcTemplate.update("""
+                        INSERT INTO job_requisitions(req_no, department_id, position_id, planned_headcount, status, opened_at)
+                        VALUES (?, ?, ?, ?, 'OPEN', now())
+                        """, "REQ%04d".formatted(i + 1), departments.get(i), positions.get(i), 3 + (i % 4));
+            }
+        }
+        int target = properties.lab().seed().candidateCount();
+        if (count("candidates") >= target) {
+            return;
+        }
+        List<Long> reqIds = jdbcTemplate.queryForList("SELECT id FROM job_requisitions ORDER BY id", Long.class);
+        List<Object[]> batch = new ArrayList<>();
+        for (int i = 1; i <= target; i++) {
+            batch.add(new Object[]{reqIds.get((i - 1) % reqIds.size()), "C%04d".formatted(i), "候选人%04d".formatted(i),
+                    "candidate%04d@example.local".formatted(i), "138%08d".formatted(i), "LOCAL", "NEW"});
+        }
+        jdbcTemplate.batchUpdate("""
+                INSERT INTO candidates(requisition_id, candidate_code, display_name, email, phone, source, status, ext_jsonb)
+                VALUES (?, ?, ?, ?, ?, ?, ?, '{}'::jsonb)
+                ON CONFLICT (candidate_code) DO NOTHING
+                """, batch);
+    }
+
+    private void seedAttendance() {
+        if (count("attendance_records") > 0) {
+            return;
+        }
+        int days = properties.lab().seed().attendanceDays();
+        int employees = properties.lab().seed().employeeCount();
+        LocalDate end = LocalDate.now(clock);
+        List<Long> employeeIds = jdbcTemplate.queryForList("SELECT id FROM employees ORDER BY employee_no LIMIT ?", Long.class, employees);
+        List<Object[]> batch = new ArrayList<>(1000);
+        for (int d = 0; d < days; d++) {
+            LocalDate workDate = end.minusDays(d);
+            for (int i = 1; i <= employeeIds.size(); i++) {
+                Long employeeId = employeeIds.get(i - 1);
+                String anomaly = (i + d) % 37 == 0 ? "MISSING_CLOCK" : ((i + d) % 19 == 0 ? "LATE" : "NORMAL");
+                batch.add(new Object[]{employeeId, Date.valueOf(workDate), "DAY",
+                        java.sql.Timestamp.valueOf(workDate.atTime(anomaly.equals("LATE") ? 9 : 8, anomaly.equals("LATE") ? 20 : 55)),
+                        anomaly.equals("MISSING_CLOCK") ? null : java.sql.Timestamp.valueOf(workDate.atTime(18, 5)),
+                        anomaly.equals("LATE") ? 20 : 0, 0, anomaly});
+                if (batch.size() >= 1000) {
+                    insertAttendance(batch);
+                    batch.clear();
+                }
+            }
+        }
+        insertAttendance(batch);
+    }
+
+    private void insertAttendance(List<Object[]> batch) {
+        if (batch.isEmpty()) {
+            return;
+        }
+        jdbcTemplate.batchUpdate("""
+                INSERT INTO attendance_records(employee_id, work_date, shift_code, clock_in_at, clock_out_at,
+                    late_minutes, early_leave_minutes, anomaly_status)
+                VALUES (?, ?, ?, ?, ?, ?, ?, ?)
+                ON CONFLICT (employee_id, work_date, shift_code) DO NOTHING
+                """, batch);
+    }
+
+    private void seedPayrollPeriods() {
+        if (count("payroll_periods") > 0) {
+            return;
+        }
+        LocalDate now = LocalDate.now(clock).withDayOfMonth(1);
+        for (int i = 2; i >= 0; i--) {
+            LocalDate start = now.minusMonths(i);
+            LocalDate end = start.plusMonths(1).minusDays(1);
+            jdbcTemplate.update("""
+                    INSERT INTO payroll_periods(period_key, start_date, end_date, status)
+                    VALUES (?, ?, ?, 'OPEN')
+                    """, "%04d-%02d".formatted(start.getYear(), start.getMonthValue()), Date.valueOf(start), Date.valueOf(end));
+        }
+        jdbcTemplate.update("""
+                INSERT INTO payroll_items(item_code, item_name, item_type, formula)
+                VALUES ('BASE', '基本工资', 'EARNING', 'ROUND(baseSalary * prorateRatio, 2)'),
+                       ('ALLOWANCE', '津贴合计', 'EARNING', 'SUM(highTemperatureDays * highTemperatureAllowanceRate, specialEnvironmentDays * specialEnvironmentAllowanceRate, holidayDays * holidayAllowanceRate, businessTripDays * businessTripAllowanceRate)'),
+                       ('ATT_DEDUCT', '考勤扣减', 'DEDUCTION', 'anomalyCount * attendanceDeductionPerAnomaly'),
+                       ('PERF', '绩效奖金', 'EARNING', 'monthlyPerformance * performanceBonusRate'),
+                       ('MANUAL', '额外奖惩', 'EARNING', 'extraRewardPenalty')
+                ON CONFLICT (item_code) DO UPDATE SET item_name = EXCLUDED.item_name, item_type = EXCLUDED.item_type,
+                    formula = EXCLUDED.formula, updated_at = now()
+                """);
+        jdbcTemplate.update("""
+                INSERT INTO payroll_variables(scope_type, department_id, variable_key, variable_name, variable_value)
+                SELECT 'DEPARTMENT', d.id, v.variable_key, v.variable_name, v.variable_value
+                FROM departments d
+                CROSS JOIN (VALUES
+                    ('baseSalary', '基本工资', 12000.0000),
+                    ('highTemperatureDays', '高温工作日', 0.0000),
+                    ('specialEnvironmentDays', '特殊环境工作日', 0.0000),
+                    ('holidayDays', '节假日工作日', 0.0000),
+                    ('businessTripDays', '出差日', 0.0000),
+                    ('monthlyPerformance', '月绩效系数', 1.0000),
+                    ('extraRewardPenalty', '额外奖惩', 0.0000),
+                    ('highTemperatureAllowanceRate', '高温日津贴标准', 80.0000),
+                    ('specialEnvironmentAllowanceRate', '特殊环境日津贴标准', 100.0000),
+                    ('holidayAllowanceRate', '节假日日津贴标准', 200.0000),
+                    ('businessTripAllowanceRate', '出差日津贴标准', 150.0000),
+                    ('performanceBonusRate', '绩效奖金基数', 1000.0000),
+                    ('attendanceDeductionPerAnomaly', '单次考勤异常扣减', 50.0000)
+                ) AS v(variable_key, variable_name, variable_value)
+                WHERE d.parent_id IS NULL
+                ON CONFLICT DO NOTHING
+                """);
+    }
+
+    private void seedPerformance() {
+        if (count("appraisal_cycles") > 0) {
+            return;
+        }
+        LocalDate now = LocalDate.now(clock);
+        for (int i = 1; i <= 2; i++) {
+            Long cycleId = jdbcTemplate.queryForObject("""
+                    INSERT INTO appraisal_cycles(cycle_name, start_date, end_date, status, template_jsonb)
+                    VALUES (?, ?, ?, 'OPEN', ?::jsonb) RETURNING id
+                    """, Long.class, "绩效周期%02d".formatted(i), Date.valueOf(now.minusMonths(6L * i)),
+                    Date.valueOf(now.plusMonths(3)), "{\"goals\":[{\"name\":\"工作质量\",\"weight\":50},{\"name\":\"协作效率\",\"weight\":50}]}");
+            for (int e = 1; e <= 50; e++) {
+                jdbcTemplate.update("""
+                        INSERT INTO appraisal_records(cycle_id, employee_id, manager_id, goals_jsonb, self_score, manager_score,
+                            final_score, final_grade, status)
+                        VALUES (?, ?, ?, '[]'::jsonb, ?, ?, ?, ?, 'COMPLETED')
+                        ON CONFLICT (cycle_id, employee_id) DO NOTHING
+                        """, cycleId, employeeId("E%04d".formatted(e)), employeeId("E0004"),
+                        new BigDecimal("82.00"), new BigDecimal("85.00"), new BigDecimal("84.00"), e % 3 == 0 ? "B" : "A");
+            }
+        }
+    }
+
+    private long count(String table) {
+        Long count = jdbcTemplate.queryForObject("SELECT count(*) FROM " + table, Long.class);
+        return count == null ? 0 : count;
+    }
+
+    private Long employeeId(String employeeNo) {
+        return jdbcTemplate.queryForObject("SELECT id FROM employees WHERE employee_no = ?", Long.class, employeeNo);
+    }
+}

+ 67 - 0
backend/src/main/java/com/example/hrlab/onboarding/CaseCrudController.java

@@ -0,0 +1,67 @@
+package com.example.hrlab.onboarding;
+
+import com.example.hrlab.audit.AuditService;
+import com.example.hrlab.common.ApiResponse;
+import com.example.hrlab.common.BusinessException;
+import com.example.hrlab.common.ErrorCodes;
+import com.example.hrlab.common.Rows;
+import java.util.Map;
+import org.springframework.http.HttpStatus;
+import org.springframework.jdbc.core.JdbcTemplate;
+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.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+@RequestMapping("/api/v1")
+public class CaseCrudController {
+    private final JdbcTemplate jdbcTemplate;
+    private final AuditService auditService;
+
+    public CaseCrudController(JdbcTemplate jdbcTemplate, AuditService auditService) {
+        this.jdbcTemplate = jdbcTemplate;
+        this.auditService = auditService;
+    }
+
+    @GetMapping("/onboarding-cases/{id}")
+    @PreAuthorize("hasAnyRole('SYSTEM_ADMIN','HR_ADMIN','AUDITOR')")
+    public ApiResponse<Map<String, Object>> onboarding(@PathVariable Long id) {
+        return ApiResponse.ok(find("onboarding_cases", id));
+    }
+
+    @DeleteMapping("/onboarding-cases/{id}")
+    @PreAuthorize("hasAnyRole('SYSTEM_ADMIN','HR_ADMIN')")
+    public ApiResponse<Map<String, Object>> cancelOnboarding(@PathVariable Long id) {
+        Map<String, Object> before = find("onboarding_cases", id);
+        jdbcTemplate.update("UPDATE onboarding_cases SET status = 'CANCELLED', version = version + 1, updated_at = now() WHERE id = ?", id);
+        auditService.record("ONBOARDING_CANCEL", "onboarding_cases", id, before, Map.of("status", "CANCELLED"));
+        return ApiResponse.ok(find("onboarding_cases", id));
+    }
+
+    @GetMapping("/offboarding-cases/{id}")
+    @PreAuthorize("hasAnyRole('SYSTEM_ADMIN','HR_ADMIN','AUDITOR')")
+    public ApiResponse<Map<String, Object>> offboarding(@PathVariable Long id) {
+        return ApiResponse.ok(find("offboarding_cases", id));
+    }
+
+    @DeleteMapping("/offboarding-cases/{id}")
+    @PreAuthorize("hasAnyRole('SYSTEM_ADMIN','HR_ADMIN')")
+    public ApiResponse<Map<String, Object>> cancelOffboarding(@PathVariable Long id) {
+        Map<String, Object> before = find("offboarding_cases", id);
+        jdbcTemplate.update("UPDATE offboarding_cases SET status = 'CANCELLED', version = version + 1, updated_at = now() WHERE id = ?", id);
+        auditService.record("OFFBOARDING_CANCEL", "offboarding_cases", id, before, Map.of("status", "CANCELLED"));
+        return ApiResponse.ok(find("offboarding_cases", id));
+    }
+
+    private Map<String, Object> find(String table, Long id) {
+        return jdbcTemplate.query("SELECT * FROM " + table + " WHERE id = ?", rs -> {
+            if (!rs.next()) {
+                throw new BusinessException(ErrorCodes.NOT_FOUND, table + " not found", HttpStatus.NOT_FOUND);
+            }
+            return Rows.map(rs);
+        }, id);
+    }
+}

+ 8 - 0
backend/src/main/java/com/example/hrlab/onboarding/CaseRequest.java

@@ -0,0 +1,8 @@
+package com.example.hrlab.onboarding;
+
+import jakarta.validation.constraints.NotNull;
+import java.time.LocalDate;
+
+public record CaseRequest(Long candidateId, @NotNull Long employeeId, String status, Object checklist,
+                          LocalDate expectedJoinDate, LocalDate requestedLeaveDate) {
+}

+ 64 - 0
backend/src/main/java/com/example/hrlab/onboarding/ChecklistNormalizer.java

@@ -0,0 +1,64 @@
+package com.example.hrlab.onboarding;
+
+import com.example.hrlab.common.BusinessException;
+import com.example.hrlab.common.ErrorCodes;
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import java.util.List;
+import java.util.Map;
+import org.springframework.http.HttpStatus;
+
+final class ChecklistNormalizer {
+    private static final TypeReference<List<Map<String, Object>>> CHECKLIST_TYPE = new TypeReference<>() {};
+
+    private final ObjectMapper objectMapper;
+
+    ChecklistNormalizer(ObjectMapper objectMapper) {
+        this.objectMapper = objectMapper;
+    }
+
+    List<Map<String, Object>> normalize(Object value, List<Map<String, Object>> fallback) {
+        Object source = unwrap(value);
+        if (source == null) {
+            return fallback;
+        }
+        if (source instanceof String text) {
+            if (text.isBlank()) {
+                return fallback;
+            }
+            try {
+                return objectMapper.readValue(text, CHECKLIST_TYPE);
+            } catch (Exception ex) {
+                throw invalidChecklist(ex);
+            }
+        }
+        try {
+            return objectMapper.convertValue(source, CHECKLIST_TYPE);
+        } catch (IllegalArgumentException ex) {
+            throw invalidChecklist(ex);
+        }
+    }
+
+    boolean allRequiredDone(Object value, List<Map<String, Object>> fallback) {
+        return normalize(value, fallback).stream()
+                .allMatch(item -> !Boolean.TRUE.equals(item.get("required")) || Boolean.TRUE.equals(item.get("done")));
+    }
+
+    private Object unwrap(Object value) {
+        if (value instanceof Map<?, ?> map && map.containsKey("value")) {
+            return map.get("value");
+        }
+        if (value != null && "org.postgresql.util.PGobject".equals(value.getClass().getName())) {
+            try {
+                return value.getClass().getMethod("getValue").invoke(value);
+            } catch (ReflectiveOperationException ex) {
+                throw invalidChecklist(ex);
+            }
+        }
+        return value;
+    }
+
+    private BusinessException invalidChecklist(Exception ex) {
+        return new BusinessException(ErrorCodes.VALIDATION_FAILED, "清单格式不正确", HttpStatus.BAD_REQUEST, ex.getMessage());
+    }
+}

+ 4 - 0
backend/src/main/java/com/example/hrlab/onboarding/CompleteChecklistRequest.java

@@ -0,0 +1,4 @@
+package com.example.hrlab.onboarding;
+
+public record CompleteChecklistRequest(Object checklist) {
+}

+ 149 - 0
backend/src/main/java/com/example/hrlab/onboarding/OffboardingController.java

@@ -0,0 +1,149 @@
+package com.example.hrlab.onboarding;
+
+import com.example.hrlab.audit.AuditService;
+import com.example.hrlab.common.ApiResponse;
+import com.example.hrlab.common.BusinessException;
+import com.example.hrlab.common.ErrorCodes;
+import com.example.hrlab.common.JsonUtils;
+import com.example.hrlab.common.PageResponse;
+import com.example.hrlab.common.Rows;
+import com.example.hrlab.integration.IntegrationEventService;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import jakarta.validation.Valid;
+import java.time.LocalDate;
+import java.time.OffsetDateTime;
+import java.util.List;
+import java.util.Map;
+import org.springframework.http.HttpStatus;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.transaction.annotation.Transactional;
+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;
+
+@RestController
+@RequestMapping("/api/v1/offboarding-cases")
+public class OffboardingController {
+    private final JdbcTemplate jdbcTemplate;
+    private final AuditService auditService;
+    private final ObjectMapper objectMapper;
+    private final IntegrationEventService integrationEventService;
+    private final ChecklistNormalizer checklistNormalizer;
+
+    public OffboardingController(JdbcTemplate jdbcTemplate, AuditService auditService, ObjectMapper objectMapper,
+                                 IntegrationEventService integrationEventService) {
+        this.jdbcTemplate = jdbcTemplate;
+        this.auditService = auditService;
+        this.objectMapper = objectMapper;
+        this.integrationEventService = integrationEventService;
+        this.checklistNormalizer = new ChecklistNormalizer(objectMapper);
+    }
+
+    @GetMapping
+    @PreAuthorize("hasAnyRole('SYSTEM_ADMIN','HR_ADMIN','AUDITOR')")
+    public ApiResponse<PageResponse<Map<String, Object>>> list(@RequestParam(defaultValue = "1") int page,
+                                                               @RequestParam(defaultValue = "20") int size) {
+        int safePage = Math.max(page, 1);
+        int safeSize = Math.min(Math.max(size, 1), 100);
+        long total = jdbcTemplate.queryForObject("SELECT count(*) FROM offboarding_cases", Long.class);
+        var rows = jdbcTemplate.query("SELECT * FROM offboarding_cases ORDER BY id DESC LIMIT ? OFFSET ?",
+                (rs, rowNum) -> Rows.map(rs), safeSize, (safePage - 1) * safeSize);
+        return ApiResponse.ok(new PageResponse<>(rows, safePage, safeSize, total));
+    }
+
+    @PostMapping
+    @PreAuthorize("hasAnyRole('SYSTEM_ADMIN','HR_ADMIN')")
+    public ApiResponse<Map<String, Object>> create(@Valid @RequestBody CaseRequest request) {
+        Long id = jdbcTemplate.queryForObject("""
+                INSERT INTO offboarding_cases(employee_id, status, requested_leave_date, checklist_jsonb)
+                VALUES (?, ?, ?, ?::jsonb) RETURNING id
+                """, Long.class, request.employeeId(), "OPEN", request.requestedLeaveDate(),
+                JsonUtils.toJson(request.checklist() == null ? defaultChecklist() : request.checklist()));
+        auditService.record("OFFBOARDING_CREATE", "offboarding_cases", id, null, request);
+        return ApiResponse.ok(find(id));
+    }
+
+    @PutMapping("/{id}")
+    @PreAuthorize("hasAnyRole('SYSTEM_ADMIN','HR_ADMIN')")
+    public ApiResponse<Map<String, Object>> update(@PathVariable Long id, @Valid @RequestBody CaseRequest request) {
+        Map<String, Object> before = find(id);
+        jdbcTemplate.update("""
+                UPDATE offboarding_cases SET employee_id = ?, status = ?, requested_leave_date = ?,
+                    checklist_jsonb = ?::jsonb, version = version + 1, updated_at = now()
+                WHERE id = ?
+                """, request.employeeId(), defaultString(request.status(), "OPEN"), request.requestedLeaveDate(),
+                JsonUtils.toJson(request.checklist() == null ? defaultChecklist() : request.checklist()), id);
+        auditService.record("OFFBOARDING_UPDATE", "offboarding_cases", id, before, request);
+        return ApiResponse.ok(find(id));
+    }
+
+    @PostMapping("/{id}/close")
+    @PreAuthorize("hasAnyRole('SYSTEM_ADMIN','HR_ADMIN')")
+    @Transactional
+    public ApiResponse<Map<String, Object>> close(@PathVariable Long id, @RequestBody(required = false) CompleteChecklistRequest request) {
+        Map<String, Object> before = find(id);
+        if ("CLOSED".equals(before.get("status"))) {
+            throw new BusinessException(ErrorCodes.BUSINESS_RULE_VIOLATION, "离职流程已关闭");
+        }
+        if ("CANCELLED".equals(before.get("status"))) {
+            throw new BusinessException(ErrorCodes.BUSINESS_RULE_VIOLATION, "离职流程已取消,不能关闭");
+        }
+        List<Map<String, Object>> checklist = checklistNormalizer.normalize(
+                request == null || request.checklist() == null ? before.get("checklist_jsonb") : request.checklist(),
+                defaultChecklist());
+        if (!checklistNormalizer.allRequiredDone(checklist, defaultChecklist())) {
+            throw new BusinessException(ErrorCodes.BUSINESS_RULE_VIOLATION, "存在未完成交接项,不能关闭离职流程");
+        }
+        Long employeeId = ((Number) before.get("employee_id")).longValue();
+        LocalDate requestedLeaveDate = toLocalDate(before.get("requested_leave_date"));
+        jdbcTemplate.update("""
+                UPDATE offboarding_cases SET status = 'CLOSED', checklist_jsonb = ?::jsonb, closed_at = ?, version = version + 1, updated_at = now()
+                WHERE id = ?
+                """, JsonUtils.toJson(checklist), OffsetDateTime.now(), id);
+        jdbcTemplate.update("""
+                UPDATE employees SET employment_status = 'LEFT', leave_date = ?, version = version + 1, updated_at = now()
+                WHERE id = ?
+                """, requestedLeaveDate, employeeId);
+        jdbcTemplate.update("UPDATE user_accounts SET status = 'DISABLED', updated_at = now() WHERE employee_id = ?", employeeId);
+        integrationEventService.createEvent("EMPLOYEE_OFFBOARDING_CLOSED", "employee:" + employeeId,
+                Map.of("employeeId", employeeId, "caseId", id));
+        auditService.record("OFFBOARDING_CLOSE", "offboarding_cases", id, before, Map.of("employeeId", employeeId));
+        return ApiResponse.ok(find(id));
+    }
+
+    private List<Map<String, Object>> defaultChecklist() {
+        return List.of(
+                Map.of("name", "资料交接", "required", true, "done", false),
+                Map.of("name", "资产交接", "required", true, "done", false),
+                Map.of("name", "账号停用", "required", true, "done", false));
+    }
+
+    private Map<String, Object> find(Long id) {
+        return jdbcTemplate.query("SELECT * FROM offboarding_cases WHERE id = ?", rs -> {
+            if (!rs.next()) {
+                throw new BusinessException(ErrorCodes.NOT_FOUND, "离职单不存在", HttpStatus.NOT_FOUND);
+            }
+            return Rows.map(rs);
+        }, id);
+    }
+
+    private String defaultString(String value, String defaultValue) {
+        return value == null || value.isBlank() ? defaultValue : value;
+    }
+
+    private LocalDate toLocalDate(Object value) {
+        if (value instanceof LocalDate date) {
+            return date;
+        }
+        if (value instanceof java.sql.Date date) {
+            return date.toLocalDate();
+        }
+        return value == null ? null : LocalDate.parse(String.valueOf(value));
+    }
+}

+ 200 - 0
backend/src/main/java/com/example/hrlab/onboarding/OnboardingController.java

@@ -0,0 +1,200 @@
+package com.example.hrlab.onboarding;
+
+import com.example.hrlab.audit.AuditService;
+import com.example.hrlab.common.ApiResponse;
+import com.example.hrlab.common.BusinessException;
+import com.example.hrlab.common.ErrorCodes;
+import com.example.hrlab.common.JsonUtils;
+import com.example.hrlab.common.PageResponse;
+import com.example.hrlab.common.Rows;
+import com.example.hrlab.integration.IntegrationEventService;
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import jakarta.validation.Valid;
+import java.time.OffsetDateTime;
+import java.util.List;
+import java.util.Map;
+import org.springframework.http.HttpStatus;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.security.crypto.password.PasswordEncoder;
+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;
+
+@RestController
+@RequestMapping("/api/v1/onboarding-cases")
+public class OnboardingController {
+    private final JdbcTemplate jdbcTemplate;
+    private final AuditService auditService;
+    private final ObjectMapper objectMapper;
+    private final PasswordEncoder passwordEncoder;
+    private final IntegrationEventService integrationEventService;
+
+    public OnboardingController(JdbcTemplate jdbcTemplate, AuditService auditService, ObjectMapper objectMapper,
+                                PasswordEncoder passwordEncoder, IntegrationEventService integrationEventService) {
+        this.jdbcTemplate = jdbcTemplate;
+        this.auditService = auditService;
+        this.objectMapper = objectMapper;
+        this.passwordEncoder = passwordEncoder;
+        this.integrationEventService = integrationEventService;
+    }
+
+    @GetMapping
+    @PreAuthorize("hasAnyRole('SYSTEM_ADMIN','HR_ADMIN','AUDITOR')")
+    public ApiResponse<PageResponse<Map<String, Object>>> list(@RequestParam(defaultValue = "1") int page,
+                                                               @RequestParam(defaultValue = "20") int size) {
+        return ApiResponse.ok(page("onboarding_cases", "id DESC", page, size));
+    }
+
+    @PostMapping
+    @PreAuthorize("hasAnyRole('SYSTEM_ADMIN','HR_ADMIN')")
+    public ApiResponse<Map<String, Object>> create(@Valid @RequestBody CaseRequest request) {
+        Long id = jdbcTemplate.queryForObject("""
+                INSERT INTO onboarding_cases(candidate_id, target_employee_id, status, checklist_jsonb, expected_join_date)
+                VALUES (?, ?, ?, ?::jsonb, ?) RETURNING id
+                """, Long.class, request.candidateId(), request.employeeId(), defaultString(request.status(), "OPEN"),
+                JsonUtils.toJson(request.checklist() == null ? defaultOnboardingChecklist() : request.checklist()),
+                request.expectedJoinDate());
+        auditService.record("ONBOARDING_CREATE", "onboarding_cases", id, null, request);
+        return ApiResponse.ok(find("onboarding_cases", id, "入职单不存在"));
+    }
+
+    @PutMapping("/{id}")
+    @PreAuthorize("hasAnyRole('SYSTEM_ADMIN','HR_ADMIN')")
+    public ApiResponse<Map<String, Object>> update(@PathVariable Long id, @Valid @RequestBody CaseRequest request) {
+        Map<String, Object> before = find("onboarding_cases", id, "入职单不存在");
+        jdbcTemplate.update("""
+                UPDATE onboarding_cases SET candidate_id = ?, target_employee_id = ?, status = ?, checklist_jsonb = ?::jsonb,
+                    expected_join_date = ?, version = version + 1, updated_at = now()
+                WHERE id = ?
+                """, request.candidateId(), request.employeeId(), defaultString(request.status(), "OPEN"),
+                JsonUtils.toJson(request.checklist() == null ? defaultOnboardingChecklist() : request.checklist()),
+                request.expectedJoinDate(), id);
+        auditService.record("ONBOARDING_UPDATE", "onboarding_cases", id, before, request);
+        return ApiResponse.ok(find("onboarding_cases", id, "入职单不存在"));
+    }
+
+    @PostMapping("/{id}/complete")
+    @PreAuthorize("hasAnyRole('SYSTEM_ADMIN','HR_ADMIN')")
+    public ApiResponse<Map<String, Object>> complete(@PathVariable Long id, @RequestBody(required = false) CompleteChecklistRequest request) {
+        Map<String, Object> before = find("onboarding_cases", id, "入职单不存在");
+        Object checklist = request == null || request.checklist() == null ? markAllDone(before.get("checklist_jsonb")) : request.checklist();
+        if (!allRequiredDone(checklist)) {
+            throw new BusinessException(ErrorCodes.BUSINESS_RULE_VIOLATION, "未完成必填入职项,不能激活账号");
+        }
+        Long employeeId = ((Number) before.get("target_employee_id")).longValue();
+        jdbcTemplate.update("UPDATE employees SET employment_status = 'ACTIVE', version = version + 1, updated_at = now() WHERE id = ?", employeeId);
+        jdbcTemplate.update("""
+                UPDATE onboarding_cases SET status = 'COMPLETED', checklist_jsonb = ?::jsonb, completed_at = ?, version = version + 1, updated_at = now()
+                WHERE id = ?
+                """, JsonUtils.toJson(checklist), OffsetDateTime.now(), id);
+        activateEmployeeAccount(employeeId);
+        integrationEventService.createEvent("EMPLOYEE_ONBOARDING_COMPLETED", "employee:" + employeeId,
+                Map.of("employeeId", employeeId, "caseId", id));
+        auditService.record("ONBOARDING_COMPLETE", "onboarding_cases", id, before, Map.of("employeeId", employeeId));
+        return ApiResponse.ok(find("onboarding_cases", id, "入职单不存在"));
+    }
+
+    @PostMapping("/{id}/no-show")
+    @PreAuthorize("hasAnyRole('SYSTEM_ADMIN','HR_ADMIN')")
+    public ApiResponse<Map<String, Object>> noShow(@PathVariable Long id) {
+        Map<String, Object> before = find("onboarding_cases", id, "入职单不存在");
+        if ("COMPLETED".equals(before.get("status"))) {
+            throw new BusinessException(ErrorCodes.BUSINESS_RULE_VIOLATION, "已完成入职的流程不能标记未到岗");
+        }
+        Long employeeId = ((Number) before.get("target_employee_id")).longValue();
+        Object candidateId = before.get("candidate_id");
+        jdbcTemplate.update("""
+                UPDATE onboarding_cases SET status = 'NO_SHOW', version = version + 1, updated_at = now()
+                WHERE id = ?
+                """, id);
+        jdbcTemplate.update("""
+                UPDATE employees SET employment_status = 'ARCHIVED', archived = true, version = version + 1, updated_at = now()
+                WHERE id = ?
+                """, employeeId);
+        if (candidateId != null) {
+            jdbcTemplate.update("""
+                    UPDATE candidates SET status = 'NO_SHOW', version = version + 1, updated_at = now()
+                    WHERE id = ?
+                    """, candidateId);
+        }
+        auditService.record("ONBOARDING_NO_SHOW", "onboarding_cases", id, before, Map.of("employeeId", employeeId));
+        return ApiResponse.ok(find("onboarding_cases", id, "入职单不存在"));
+    }
+
+    private void activateEmployeeAccount(Long employeeId) {
+        Map<String, Object> employee = jdbcTemplate.query("SELECT employee_no FROM employees WHERE id = ?", rs -> {
+            if (!rs.next()) {
+                throw new BusinessException(ErrorCodes.NOT_FOUND, "员工不存在", HttpStatus.NOT_FOUND);
+            }
+            return Rows.map(rs);
+        }, employeeId);
+        Integer count = jdbcTemplate.queryForObject("SELECT count(*) FROM user_accounts WHERE employee_id = ?", Integer.class, employeeId);
+        if (count != null && count > 0) {
+            jdbcTemplate.update("UPDATE user_accounts SET status = 'ACTIVE', updated_at = now() WHERE employee_id = ?", employeeId);
+            return;
+        }
+        Long userId = jdbcTemplate.queryForObject("""
+                INSERT INTO user_accounts(employee_id, username, password_hash, status)
+                VALUES (?, ?, ?, 'ACTIVE') RETURNING id
+                """, Long.class, employeeId, String.valueOf(employee.get("employee_no")).toLowerCase().replace("e-", "employee_"),
+                passwordEncoder.encode("ChangeMe123!"));
+        jdbcTemplate.update("""
+                INSERT INTO user_roles(user_account_id, role_id)
+                SELECT ?, id FROM roles WHERE role_code = 'EMPLOYEE_SELF'
+                """, userId);
+    }
+
+    private List<Map<String, Object>> defaultOnboardingChecklist() {
+        return List.of(
+                Map.of("name", "资料确认", "required", true, "done", false),
+                Map.of("name", "账号准备", "required", true, "done", false),
+                Map.of("name", "岗位确认", "required", true, "done", false));
+    }
+
+    private Object markAllDone(Object checklist) {
+        List<Map<String, Object>> items = toChecklist(checklist);
+        return items.stream().map(item -> {
+            java.util.Map<String, Object> copy = new java.util.LinkedHashMap<>(item);
+            copy.put("done", true);
+            return copy;
+        }).toList();
+    }
+
+    private boolean allRequiredDone(Object checklist) {
+        return toChecklist(checklist).stream()
+                .allMatch(item -> !Boolean.TRUE.equals(item.get("required")) || Boolean.TRUE.equals(item.get("done")));
+    }
+
+    private List<Map<String, Object>> toChecklist(Object value) {
+        return objectMapper.convertValue(value, new TypeReference<>() {});
+    }
+
+    private PageResponse<Map<String, Object>> page(String table, String order, int page, int size) {
+        int safePage = Math.max(page, 1);
+        int safeSize = Math.min(Math.max(size, 1), 100);
+        long total = jdbcTemplate.queryForObject("SELECT count(*) FROM " + table, Long.class);
+        var rows = jdbcTemplate.query("SELECT * FROM " + table + " ORDER BY " + order + " LIMIT ? OFFSET ?",
+                (rs, rowNum) -> Rows.map(rs), safeSize, (safePage - 1) * safeSize);
+        return new PageResponse<>(rows, safePage, safeSize, total);
+    }
+
+    private Map<String, Object> find(String table, Long id, String message) {
+        return jdbcTemplate.query("SELECT * FROM " + table + " WHERE id = ?", rs -> {
+            if (!rs.next()) {
+                throw new BusinessException(ErrorCodes.NOT_FOUND, message, HttpStatus.NOT_FOUND);
+            }
+            return Rows.map(rs);
+        }, id);
+    }
+
+    private String defaultString(String value, String defaultValue) {
+        return value == null || value.isBlank() ? defaultValue : value;
+    }
+}

+ 75 - 0
backend/src/main/java/com/example/hrlab/organization/DepartmentEntity.java

@@ -0,0 +1,75 @@
+package com.example.hrlab.organization;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.Table;
+import java.time.OffsetDateTime;
+
+@Entity
+@Table(name = "departments")
+public class DepartmentEntity {
+    @Id
+    @GeneratedValue(strategy = GenerationType.IDENTITY)
+    private Long id;
+
+    @Column(name = "parent_id")
+    private Long parentId;
+
+    @Column(name = "dept_code", nullable = false, unique = true)
+    private String deptCode;
+
+    @Column(name = "dept_name", nullable = false)
+    private String deptName;
+
+    private String status;
+
+    @Column(name = "sort_no")
+    private Integer sortNo;
+
+    private String path;
+
+    @Column(name = "created_at")
+    private OffsetDateTime createdAt;
+
+    @Column(name = "updated_at")
+    private OffsetDateTime updatedAt;
+
+    public Long getId() {
+        return id;
+    }
+
+    public String getDeptCode() {
+        return deptCode;
+    }
+
+    public String getDeptName() {
+        return deptName;
+    }
+
+    public Long getParentId() {
+        return parentId;
+    }
+
+    public String getStatus() {
+        return status;
+    }
+
+    public Integer getSortNo() {
+        return sortNo;
+    }
+
+    public String getPath() {
+        return path;
+    }
+
+    public OffsetDateTime getCreatedAt() {
+        return createdAt;
+    }
+
+    public OffsetDateTime getUpdatedAt() {
+        return updatedAt;
+    }
+}

+ 8 - 0
backend/src/main/java/com/example/hrlab/organization/DepartmentRepository.java

@@ -0,0 +1,8 @@
+package com.example.hrlab.organization;
+
+import java.util.List;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+public interface DepartmentRepository extends JpaRepository<DepartmentEntity, Long> {
+    List<DepartmentEntity> findByStatusOrderByPathAscSortNoAsc(String status);
+}

+ 7 - 0
backend/src/main/java/com/example/hrlab/organization/DepartmentRequest.java

@@ -0,0 +1,7 @@
+package com.example.hrlab.organization;
+
+import jakarta.validation.constraints.NotBlank;
+
+public record DepartmentRequest(Long parentId, @NotBlank String deptCode, @NotBlank String deptName,
+                                String status, Integer sortNo) {
+}

+ 23 - 0
backend/src/main/java/com/example/hrlab/organization/JpaOrganizationController.java

@@ -0,0 +1,23 @@
+package com.example.hrlab.organization;
+
+import com.example.hrlab.common.ApiResponse;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+@RequestMapping("/api/v1/organization")
+public class JpaOrganizationController {
+    private final DepartmentRepository departmentRepository;
+
+    public JpaOrganizationController(DepartmentRepository departmentRepository) {
+        this.departmentRepository = departmentRepository;
+    }
+
+    @GetMapping("/active-departments")
+    @PreAuthorize("isAuthenticated()")
+    public ApiResponse<Object> activeDepartments() {
+        return ApiResponse.ok(departmentRepository.findByStatusOrderByPathAscSortNoAsc("ACTIVE"));
+    }
+}

+ 157 - 0
backend/src/main/java/com/example/hrlab/organization/OrganizationController.java

@@ -0,0 +1,157 @@
+package com.example.hrlab.organization;
+
+import com.example.hrlab.audit.AuditService;
+import com.example.hrlab.common.ApiResponse;
+import com.example.hrlab.common.BusinessException;
+import com.example.hrlab.common.ErrorCodes;
+import com.example.hrlab.common.PageResponse;
+import com.example.hrlab.common.Rows;
+import jakarta.validation.Valid;
+import java.util.Map;
+import org.springframework.http.HttpStatus;
+import org.springframework.jdbc.core.JdbcTemplate;
+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;
+
+@RestController
+@RequestMapping("/api/v1")
+public class OrganizationController {
+    private final JdbcTemplate jdbcTemplate;
+    private final AuditService auditService;
+
+    public OrganizationController(JdbcTemplate jdbcTemplate, AuditService auditService) {
+        this.jdbcTemplate = jdbcTemplate;
+        this.auditService = auditService;
+    }
+
+    @GetMapping("/departments")
+    @PreAuthorize("isAuthenticated()")
+    public ApiResponse<PageResponse<Map<String, Object>>> departments(@RequestParam(defaultValue = "1") int page,
+                                                                       @RequestParam(defaultValue = "200") int size) {
+        int safePage = Math.max(page, 1);
+        int safeSize = Math.min(Math.max(size, 1), 500);
+        long total = jdbcTemplate.queryForObject("SELECT count(*) FROM departments", Long.class);
+        var rows = jdbcTemplate.query("SELECT * FROM departments ORDER BY path, sort_no LIMIT ? OFFSET ?",
+                (rs, rowNum) -> Rows.map(rs), safeSize, (safePage - 1) * safeSize);
+        return ApiResponse.ok(new PageResponse<>(rows, safePage, safeSize, total));
+    }
+
+    @PostMapping("/departments")
+    @PreAuthorize("hasAnyRole('SYSTEM_ADMIN','HR_ADMIN')")
+    public ApiResponse<Map<String, Object>> createDepartment(@Valid @RequestBody DepartmentRequest request) {
+        Long id = jdbcTemplate.queryForObject("""
+                INSERT INTO departments(parent_id, dept_code, dept_name, status, sort_no)
+                VALUES (?, ?, ?, ?, ?) RETURNING id
+                """, Long.class, request.parentId(), request.deptCode(), request.deptName(),
+                defaultString(request.status(), "ACTIVE"), request.sortNo() == null ? 0 : request.sortNo());
+        updateDepartmentPath(id, request.parentId());
+        auditService.record("DEPARTMENT_CREATE", "departments", id, null, request);
+        return ApiResponse.ok(findDepartment(id));
+    }
+
+    @PutMapping("/departments/{id}")
+    @PreAuthorize("hasAnyRole('SYSTEM_ADMIN','HR_ADMIN')")
+    public ApiResponse<Map<String, Object>> updateDepartment(@PathVariable Long id, @Valid @RequestBody DepartmentRequest request) {
+        Map<String, Object> before = findDepartment(id);
+        jdbcTemplate.update("""
+                UPDATE departments SET parent_id = ?, dept_code = ?, dept_name = ?, status = ?, sort_no = ?, updated_at = now()
+                WHERE id = ?
+                """, request.parentId(), request.deptCode(), request.deptName(), defaultString(request.status(), "ACTIVE"),
+                request.sortNo() == null ? 0 : request.sortNo(), id);
+        updateDepartmentPath(id, request.parentId());
+        auditService.record("DEPARTMENT_UPDATE", "departments", id, before, request);
+        return ApiResponse.ok(findDepartment(id));
+    }
+
+    @DeleteMapping("/departments/{id}")
+    @PreAuthorize("hasAnyRole('SYSTEM_ADMIN','HR_ADMIN')")
+    public ApiResponse<Map<String, Object>> disableDepartment(@PathVariable Long id) {
+        Map<String, Object> before = findDepartment(id);
+        jdbcTemplate.update("UPDATE departments SET status = 'INACTIVE', updated_at = now() WHERE id = ?", id);
+        auditService.record("DEPARTMENT_DISABLE", "departments", id, before, Map.of("status", "INACTIVE"));
+        return ApiResponse.ok(findDepartment(id));
+    }
+
+    @GetMapping("/positions")
+    @PreAuthorize("isAuthenticated()")
+    public ApiResponse<PageResponse<Map<String, Object>>> positions(@RequestParam(defaultValue = "1") int page,
+                                                                    @RequestParam(defaultValue = "100") int size) {
+        int safePage = Math.max(page, 1);
+        int safeSize = Math.min(Math.max(size, 1), 200);
+        long total = jdbcTemplate.queryForObject("SELECT count(*) FROM positions", Long.class);
+        var rows = jdbcTemplate.query("SELECT * FROM positions ORDER BY position_code LIMIT ? OFFSET ?",
+                (rs, rowNum) -> Rows.map(rs), safeSize, (safePage - 1) * safeSize);
+        return ApiResponse.ok(new PageResponse<>(rows, safePage, safeSize, total));
+    }
+
+    @PostMapping("/positions")
+    @PreAuthorize("hasAnyRole('SYSTEM_ADMIN','HR_ADMIN')")
+    public ApiResponse<Map<String, Object>> createPosition(@Valid @RequestBody PositionRequest request) {
+        Long id = jdbcTemplate.queryForObject("""
+                INSERT INTO positions(position_code, position_name, level_code, status)
+                VALUES (?, ?, ?, ?) RETURNING id
+                """, Long.class, request.positionCode(), request.positionName(), request.levelCode(),
+                defaultString(request.status(), "ACTIVE"));
+        auditService.record("POSITION_CREATE", "positions", id, null, request);
+        return ApiResponse.ok(findPosition(id));
+    }
+
+    @PutMapping("/positions/{id}")
+    @PreAuthorize("hasAnyRole('SYSTEM_ADMIN','HR_ADMIN')")
+    public ApiResponse<Map<String, Object>> updatePosition(@PathVariable Long id, @Valid @RequestBody PositionRequest request) {
+        Map<String, Object> before = findPosition(id);
+        jdbcTemplate.update("""
+                UPDATE positions SET position_code = ?, position_name = ?, level_code = ?, status = ?, updated_at = now()
+                WHERE id = ?
+                """, request.positionCode(), request.positionName(), request.levelCode(), defaultString(request.status(), "ACTIVE"), id);
+        auditService.record("POSITION_UPDATE", "positions", id, before, request);
+        return ApiResponse.ok(findPosition(id));
+    }
+
+    @DeleteMapping("/positions/{id}")
+    @PreAuthorize("hasAnyRole('SYSTEM_ADMIN','HR_ADMIN')")
+    public ApiResponse<Map<String, Object>> disablePosition(@PathVariable Long id) {
+        Map<String, Object> before = findPosition(id);
+        jdbcTemplate.update("UPDATE positions SET status = 'INACTIVE', updated_at = now() WHERE id = ?", id);
+        auditService.record("POSITION_DISABLE", "positions", id, before, Map.of("status", "INACTIVE"));
+        return ApiResponse.ok(findPosition(id));
+    }
+
+    private void updateDepartmentPath(Long id, Long parentId) {
+        String parentPath = "";
+        if (parentId != null) {
+            parentPath = jdbcTemplate.queryForObject("SELECT path FROM departments WHERE id = ?", String.class, parentId);
+        }
+        jdbcTemplate.update("UPDATE departments SET path = ?, updated_at = now() WHERE id = ?", parentPath + "/" + id, id);
+    }
+
+    private Map<String, Object> findDepartment(Long id) {
+        return jdbcTemplate.query("SELECT * FROM departments WHERE id = ?", rs -> {
+            if (!rs.next()) {
+                throw new BusinessException(ErrorCodes.NOT_FOUND, "部门不存在", HttpStatus.NOT_FOUND);
+            }
+            return Rows.map(rs);
+        }, id);
+    }
+
+    private Map<String, Object> findPosition(Long id) {
+        return jdbcTemplate.query("SELECT * FROM positions WHERE id = ?", rs -> {
+            if (!rs.next()) {
+                throw new BusinessException(ErrorCodes.NOT_FOUND, "岗位不存在", HttpStatus.NOT_FOUND);
+            }
+            return Rows.map(rs);
+        }, id);
+    }
+
+    private String defaultString(String value, String defaultValue) {
+        return value == null || value.isBlank() ? defaultValue : value;
+    }
+}

+ 7 - 0
backend/src/main/java/com/example/hrlab/organization/PositionRequest.java

@@ -0,0 +1,7 @@
+package com.example.hrlab.organization;
+
+import jakarta.validation.constraints.NotBlank;
+
+public record PositionRequest(@NotBlank String positionCode, @NotBlank String positionName,
+                              @NotBlank String levelCode, String status) {
+}

+ 43 - 0
backend/src/main/java/com/example/hrlab/payroll/PayrollCalculator.java

@@ -0,0 +1,43 @@
+package com.example.hrlab.payroll;
+
+import com.example.hrlab.common.BusinessException;
+import com.example.hrlab.common.ErrorCodes;
+import com.example.hrlab.config.AppProperties;
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import org.springframework.stereotype.Service;
+
+@Service
+public class PayrollCalculator {
+    private final AppProperties properties;
+
+    public PayrollCalculator(AppProperties properties) {
+        this.properties = properties;
+    }
+
+    public void verifyFormulaAvailable() {
+        if (properties.lab().failpoint().formulaException()) {
+            throw new BusinessException(ErrorCodes.BUSINESS_RULE_VIOLATION, "工资公式异常已触发");
+        }
+    }
+
+    public PayslipAmount calculate(BigDecimal baseSalary, BigDecimal allowanceTotal, BigDecimal attendanceDeduction,
+                                   BigDecimal performanceBonus, BigDecimal manualAdjustment, BigDecimal prorateRatio) {
+        verifyFormulaAvailable();
+        BigDecimal ratio = prorateRatio == null ? BigDecimal.ONE : prorateRatio;
+        BigDecimal proratedBase = money(baseSalary.multiply(ratio));
+        BigDecimal deductionTotal = money(attendanceDeduction.max(BigDecimal.ZERO));
+        BigDecimal net = money(proratedBase.add(allowanceTotal).add(performanceBonus).add(manualAdjustment).subtract(deductionTotal));
+        return new PayslipAmount(proratedBase, allowanceTotal, attendanceDeduction, performanceBonus,
+                manualAdjustment, deductionTotal, net);
+    }
+
+    private BigDecimal money(BigDecimal value) {
+        return value.setScale(2, RoundingMode.HALF_UP);
+    }
+
+    public record PayslipAmount(BigDecimal baseSalary, BigDecimal allowanceTotal, BigDecimal attendanceDeduction,
+                                BigDecimal performanceBonus, BigDecimal manualAdjustment,
+                                BigDecimal deductionTotal, BigDecimal netAmount) {
+    }
+}

+ 250 - 0
backend/src/main/java/com/example/hrlab/payroll/PayrollController.java

@@ -0,0 +1,250 @@
+package com.example.hrlab.payroll;
+
+import com.example.hrlab.audit.AuditService;
+import com.example.hrlab.common.ApiResponse;
+import com.example.hrlab.common.BusinessException;
+import com.example.hrlab.common.ErrorCodes;
+import com.example.hrlab.common.PageResponse;
+import com.example.hrlab.common.Rows;
+import com.example.hrlab.security.DataScopeService;
+import com.example.hrlab.security.SecurityUtils;
+import jakarta.validation.Valid;
+import java.util.Map;
+import org.springframework.http.HttpStatus;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.security.access.prepost.PreAuthorize;
+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.DeleteMapping;
+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;
+
+@RestController
+@RequestMapping("/api/v1")
+public class PayrollController {
+    private final JdbcTemplate jdbcTemplate;
+    private final PayrollService payrollService;
+    private final AuditService auditService;
+    private final DataScopeService dataScopeService;
+
+    public PayrollController(JdbcTemplate jdbcTemplate, PayrollService payrollService, AuditService auditService,
+                             DataScopeService dataScopeService) {
+        this.jdbcTemplate = jdbcTemplate;
+        this.payrollService = payrollService;
+        this.auditService = auditService;
+        this.dataScopeService = dataScopeService;
+    }
+
+    @GetMapping("/payroll/periods")
+    @PreAuthorize("hasAnyRole('SYSTEM_ADMIN','PAYROLL_ADMIN','AUDITOR')")
+    public ApiResponse<PageResponse<Map<String, Object>>> periods(@RequestParam(defaultValue = "1") int page,
+                                                                  @RequestParam(defaultValue = "20") int size) {
+        return ApiResponse.ok(page("payroll_periods", "period_key DESC", page, size));
+    }
+
+    @PostMapping("/payroll/periods")
+    @PreAuthorize("hasAnyRole('SYSTEM_ADMIN','PAYROLL_ADMIN')")
+    public ApiResponse<Map<String, Object>> createPeriod(@Valid @RequestBody PayrollPeriodRequest request) {
+        if (request.startDate().isAfter(request.endDate())) {
+            throw new BusinessException(ErrorCodes.BUSINESS_RULE_VIOLATION, "薪酬期间开始日期不能晚于结束日期");
+        }
+        Long id = jdbcTemplate.queryForObject("""
+                INSERT INTO payroll_periods(period_key, start_date, end_date, status)
+                VALUES (?, ?, ?, ?) RETURNING id
+                """, Long.class, request.periodKey(), request.startDate(), request.endDate(),
+                request.status() == null ? "OPEN" : request.status());
+        auditService.record("PAYROLL_PERIOD_CREATE", "payroll_periods", id, null, request);
+        return ApiResponse.ok(find("payroll_periods", id, "薪酬期间不存在"));
+    }
+
+    @PutMapping("/payroll/periods/{id}")
+    @PreAuthorize("hasAnyRole('SYSTEM_ADMIN','PAYROLL_ADMIN')")
+    public ApiResponse<Map<String, Object>> updatePeriod(@PathVariable Long id, @Valid @RequestBody PayrollPeriodRequest request) {
+        Map<String, Object> before = find("payroll_periods", id, "薪酬期间不存在");
+        if ("LOCKED".equals(before.get("status"))) {
+            throw new BusinessException(ErrorCodes.BUSINESS_RULE_VIOLATION, "薪酬期间已锁定");
+        }
+        jdbcTemplate.update("""
+                UPDATE payroll_periods SET period_key = ?, start_date = ?, end_date = ?, status = ?, updated_at = now()
+                WHERE id = ?
+                """, request.periodKey(), request.startDate(), request.endDate(), request.status() == null ? "OPEN" : request.status(), id);
+        auditService.record("PAYROLL_PERIOD_UPDATE", "payroll_periods", id, before, request);
+        return ApiResponse.ok(find("payroll_periods", id, "薪酬期间不存在"));
+    }
+
+    @DeleteMapping("/payroll/periods/{id}")
+    @PreAuthorize("hasAnyRole('SYSTEM_ADMIN','PAYROLL_ADMIN')")
+    public ApiResponse<Map<String, Object>> closePeriod(@PathVariable Long id) {
+        Map<String, Object> before = find("payroll_periods", id, "薪酬期间不存在");
+        jdbcTemplate.update("UPDATE payroll_periods SET status = 'CLOSED', updated_at = now() WHERE id = ?", id);
+        auditService.record("PAYROLL_PERIOD_CLOSE", "payroll_periods", id, before, Map.of("status", "CLOSED"));
+        return ApiResponse.ok(find("payroll_periods", id, "薪酬期间不存在"));
+    }
+
+    @PostMapping("/payroll/periods/{periodKey}/runs")
+    @PreAuthorize("hasAnyRole('SYSTEM_ADMIN','PAYROLL_ADMIN')")
+    public ApiResponse<Map<String, Object>> submitRun(@PathVariable String periodKey, @RequestBody PayrollRunRequest request) {
+        Long runId = payrollService.submitRun(periodKey, request == null ? new PayrollRunRequest(false, java.util.List.of(), "v1.0.0") : request);
+        return ApiResponse.accepted(find("payroll_runs", runId, "工资批次不存在"));
+    }
+
+    @GetMapping("/payroll/runs")
+    @PreAuthorize("hasAnyRole('SYSTEM_ADMIN','PAYROLL_ADMIN','AUDITOR')")
+    public ApiResponse<PageResponse<Map<String, Object>>> runs(@RequestParam(defaultValue = "1") int page,
+                                                              @RequestParam(defaultValue = "20") int size,
+                                                              @RequestParam(required = false) String periodKey) {
+        int safePage = Math.max(page, 1);
+        int safeSize = Math.min(Math.max(size, 1), 100);
+        Object[] args = periodKey == null || periodKey.isBlank()
+                ? new Object[]{safeSize, (safePage - 1) * safeSize}
+                : new Object[]{periodKey, safeSize, (safePage - 1) * safeSize};
+        long total = periodKey == null || periodKey.isBlank()
+                ? jdbcTemplate.queryForObject("SELECT count(*) FROM payroll_runs", Long.class)
+                : jdbcTemplate.queryForObject("""
+                    SELECT count(*) FROM payroll_runs pr
+                    JOIN payroll_periods pp ON pp.id = pr.payroll_period_id
+                    WHERE pp.period_key = ?
+                    """, Long.class, periodKey);
+        String where = periodKey == null || periodKey.isBlank() ? "" : " WHERE pp.period_key = ? ";
+        var rows = jdbcTemplate.query("""
+                SELECT pr.*, pp.period_key FROM payroll_runs pr
+                JOIN payroll_periods pp ON pp.id = pr.payroll_period_id
+                """ + where + " ORDER BY pr.id DESC LIMIT ? OFFSET ?",
+                (rs, rowNum) -> Rows.map(rs), args);
+        return ApiResponse.ok(new PageResponse<>(rows, safePage, safeSize, total));
+    }
+
+    @GetMapping("/payroll/runs/{id}")
+    @PreAuthorize("hasAnyRole('SYSTEM_ADMIN','PAYROLL_ADMIN','AUDITOR')")
+    public ApiResponse<Map<String, Object>> runDetail(@PathVariable Long id) {
+        return ApiResponse.ok(jdbcTemplate.query("""
+                SELECT pr.*, pp.period_key, pp.start_date, pp.end_date FROM payroll_runs pr
+                JOIN payroll_periods pp ON pp.id = pr.payroll_period_id
+                WHERE pr.id = ?
+                """, rs -> {
+            if (!rs.next()) {
+                throw new BusinessException(ErrorCodes.NOT_FOUND, "工资批次不存在", HttpStatus.NOT_FOUND);
+            }
+            return Rows.map(rs);
+        }, id));
+    }
+
+    @PostMapping("/payroll/runs/{id}/review")
+    @PreAuthorize("hasAnyRole('SYSTEM_ADMIN','PAYROLL_ADMIN')")
+    public ApiResponse<Map<String, Object>> review(@PathVariable Long id) {
+        payrollService.transitionRun(id, "REVIEWED", "PAYROLL_RUN_REVIEW");
+        return ApiResponse.ok(find("payroll_runs", id, "工资批次不存在"));
+    }
+
+    @PostMapping("/payroll/runs/{id}/approve")
+    @PreAuthorize("hasAnyRole('SYSTEM_ADMIN','PAYROLL_ADMIN')")
+    public ApiResponse<Map<String, Object>> approve(@PathVariable Long id) {
+        payrollService.transitionRun(id, "APPROVED", "PAYROLL_RUN_APPROVE");
+        return ApiResponse.ok(find("payroll_runs", id, "工资批次不存在"));
+    }
+
+    @PostMapping("/payroll/runs/{id}/lock")
+    @PreAuthorize("hasAnyRole('SYSTEM_ADMIN','PAYROLL_ADMIN')")
+    public ApiResponse<Map<String, Object>> lock(@PathVariable Long id) {
+        Map<String, Object> before = find("payroll_runs", id, "工资批次不存在");
+        jdbcTemplate.update("UPDATE payroll_runs SET status = 'LOCKED', version = version + 1, updated_at = now() WHERE id = ?", id);
+        auditService.record("PAYROLL_RUN_LOCK", "payroll_runs", id, before, Map.of("status", "LOCKED"));
+        return ApiResponse.ok(find("payroll_runs", id, "工资批次不存在"));
+    }
+
+    @GetMapping("/payslips")
+    @PreAuthorize("isAuthenticated()")
+    public ApiResponse<PageResponse<Map<String, Object>>> payslips(@RequestParam(defaultValue = "1") int page,
+                                                                   @RequestParam(defaultValue = "20") int size,
+                                                                   @RequestParam(required = false) String periodKey,
+                                                                   @RequestParam(required = false) Long runId) {
+        int safePage = Math.max(page, 1);
+        int safeSize = Math.min(Math.max(size, 1), 100);
+        java.util.List<Object> params = new java.util.ArrayList<>();
+        java.util.List<String> conditions = new java.util.ArrayList<>();
+        if (SecurityUtils.currentUser().orElseThrow().hasRole("EMPLOYEE_SELF")) {
+            conditions.add("ps.employee_id = ?");
+            params.add(SecurityUtils.currentUser().orElseThrow().employeeId());
+        }
+        if (periodKey != null && !periodKey.isBlank()) {
+            conditions.add("pp.period_key = ?");
+            params.add(periodKey);
+        }
+        if (runId != null) {
+            conditions.add("ps.payroll_run_id = ?");
+            params.add(runId);
+        }
+        String where = conditions.isEmpty() ? "" : " WHERE " + String.join(" AND ", conditions);
+        long total = jdbcTemplate.queryForObject("""
+                SELECT count(*) FROM payslips ps
+                JOIN payroll_runs pr ON pr.id = ps.payroll_run_id
+                JOIN payroll_periods pp ON pp.id = pr.payroll_period_id
+                """ + where, Long.class, params.toArray());
+        params.add(safeSize);
+        params.add((safePage - 1) * safeSize);
+        var rows = jdbcTemplate.query("""
+                SELECT ps.*, pp.period_key, pr.run_no, e.employee_no, e.display_name FROM payslips ps
+                JOIN payroll_runs pr ON pr.id = ps.payroll_run_id
+                JOIN payroll_periods pp ON pp.id = pr.payroll_period_id
+                JOIN employees e ON e.id = ps.employee_id
+                """ + where + " ORDER BY ps.id DESC LIMIT ? OFFSET ?",
+                (rs, rowNum) -> Rows.map(rs), params.toArray());
+        return ApiResponse.ok(new PageResponse<>(rows, safePage, safeSize, total));
+    }
+
+    @GetMapping("/payslips/{id}")
+    @PreAuthorize("isAuthenticated()")
+    public ApiResponse<Map<String, Object>> payslipDetail(@PathVariable Long id) {
+        Map<String, Object> payslip = jdbcTemplate.query("""
+                SELECT ps.*, pp.period_key, pr.run_no, e.employee_no, e.display_name FROM payslips ps
+                JOIN payroll_runs pr ON pr.id = ps.payroll_run_id
+                JOIN payroll_periods pp ON pp.id = pr.payroll_period_id
+                JOIN employees e ON e.id = ps.employee_id
+                WHERE ps.id = ?
+                """, rs -> {
+            if (!rs.next()) {
+                throw new BusinessException(ErrorCodes.NOT_FOUND, "工资单不存在", HttpStatus.NOT_FOUND);
+            }
+            return Rows.map(rs);
+        }, id);
+        dataScopeService.requireEmployeeAccess(((Number) payslip.get("employee_id")).longValue());
+        return ApiResponse.ok(payslip);
+    }
+
+    @PostMapping("/payslips/publish-batch")
+    @PreAuthorize("hasAnyRole('SYSTEM_ADMIN','PAYROLL_ADMIN')")
+    public ApiResponse<Map<String, Object>> publishPayslips(@RequestBody(required = false) PayslipBatchPublishRequest request) {
+        int count = payrollService.publishPayslips(request == null ? null : request.periodKey(),
+                request == null ? null : request.runId());
+        return ApiResponse.ok(Map.of("publishedCount", count));
+    }
+
+    @PostMapping("/payslips/{id}/publish")
+    @PreAuthorize("hasAnyRole('SYSTEM_ADMIN','PAYROLL_ADMIN')")
+    public ApiResponse<Map<String, Object>> publishPayslip(@PathVariable Long id) {
+        payrollService.publishPayslip(id);
+        return ApiResponse.ok(find("payslips", id, "工资单不存在"));
+    }
+
+    private PageResponse<Map<String, Object>> page(String table, String order, int page, int size) {
+        int safePage = Math.max(page, 1);
+        int safeSize = Math.min(Math.max(size, 1), 100);
+        long total = jdbcTemplate.queryForObject("SELECT count(*) FROM " + table, Long.class);
+        var rows = jdbcTemplate.query("SELECT * FROM " + table + " ORDER BY " + order + " LIMIT ? OFFSET ?",
+                (rs, rowNum) -> Rows.map(rs), safeSize, (safePage - 1) * safeSize);
+        return new PageResponse<>(rows, safePage, safeSize, total);
+    }
+
+    private Map<String, Object> find(String table, Long id, String message) {
+        return jdbcTemplate.query("SELECT * FROM " + table + " WHERE id = ?", rs -> {
+            if (!rs.next()) {
+                throw new BusinessException(ErrorCodes.NOT_FOUND, message, HttpStatus.NOT_FOUND);
+            }
+            return Rows.map(rs);
+        }, id);
+    }
+}

+ 259 - 0
backend/src/main/java/com/example/hrlab/payroll/PayrollFormulaEngine.java

@@ -0,0 +1,259 @@
+package com.example.hrlab.payroll;
+
+import com.example.hrlab.common.BusinessException;
+import com.example.hrlab.common.ErrorCodes;
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import org.springframework.stereotype.Service;
+
+@Service
+public class PayrollFormulaEngine {
+    public BigDecimal evaluate(String formula, Map<String, BigDecimal> variables) {
+        if (formula == null || formula.isBlank()) {
+            return BigDecimal.ZERO;
+        }
+        Parser parser = new Parser(formula, variables);
+        BigDecimal result = parser.parse();
+        return result.setScale(2, RoundingMode.HALF_UP);
+    }
+
+    public void validate(String formula) {
+        if (formula == null || formula.isBlank()) {
+            return;
+        }
+        evaluate(formula, Map.ofEntries(
+                Map.entry("baseSalary", new BigDecimal("12000")),
+                Map.entry("standardBaseSalary", new BigDecimal("12000")),
+                Map.entry("proratedBaseSalary", new BigDecimal("12000")),
+                Map.entry("allowanceTotal", new BigDecimal("800")),
+                Map.entry("attendanceDeduction", new BigDecimal("50")),
+                Map.entry("performanceBonus", new BigDecimal("1000")),
+                Map.entry("manualAdjustment", BigDecimal.ZERO),
+                Map.entry("deductionTotal", new BigDecimal("50")),
+                Map.entry("netAmount", new BigDecimal("13750")),
+                Map.entry("prorateRatio", BigDecimal.ONE),
+                Map.entry("anomalyCount", BigDecimal.ONE),
+                Map.entry("paidDays", new BigDecimal("30")),
+                Map.entry("totalDays", new BigDecimal("30")),
+                Map.entry("workHours", new BigDecimal("160")),
+                Map.entry("attendanceDays", new BigDecimal("20")),
+                Map.entry("absenceDays", BigDecimal.ZERO),
+                Map.entry("highTemperatureDays", BigDecimal.ZERO),
+                Map.entry("specialEnvironmentDays", BigDecimal.ZERO),
+                Map.entry("holidayDays", BigDecimal.ZERO),
+                Map.entry("businessTripDays", BigDecimal.ZERO),
+                Map.entry("monthlyPerformance", BigDecimal.ONE),
+                Map.entry("extraRewardPenalty", BigDecimal.ZERO),
+                Map.entry("highTemperatureAllowanceRate", new BigDecimal("80")),
+                Map.entry("specialEnvironmentAllowanceRate", new BigDecimal("100")),
+                Map.entry("holidayAllowanceRate", new BigDecimal("200")),
+                Map.entry("businessTripAllowanceRate", new BigDecimal("150")),
+                Map.entry("performanceBonusRate", new BigDecimal("1000")),
+                Map.entry("attendanceDeductionPerAnomaly", new BigDecimal("50"))));
+    }
+
+    private static final class Parser {
+        private final String input;
+        private final Map<String, BigDecimal> variables;
+        private int pos;
+
+        private Parser(String formula, Map<String, BigDecimal> variables) {
+            this.input = formula.trim().startsWith("=") ? formula.trim().substring(1) : formula.trim();
+            this.variables = variables;
+        }
+
+        private BigDecimal parse() {
+            BigDecimal value = comparison();
+            skipSpace();
+            if (pos != input.length()) {
+                throw invalid("公式存在无法识别的内容:" + input.substring(pos));
+            }
+            return value;
+        }
+
+        private BigDecimal comparison() {
+            BigDecimal left = additive();
+            skipSpace();
+            String op = null;
+            if (match(">=")) op = ">=";
+            else if (match("<=")) op = "<=";
+            else if (match("==")) op = "==";
+            else if (match("!=")) op = "!=";
+            else if (match(">")) op = ">";
+            else if (match("<")) op = "<";
+            if (op == null) {
+                return left;
+            }
+            BigDecimal right = additive();
+            int compare = left.compareTo(right);
+            boolean result = switch (op) {
+                case ">=" -> compare >= 0;
+                case "<=" -> compare <= 0;
+                case "==" -> compare == 0;
+                case "!=" -> compare != 0;
+                case ">" -> compare > 0;
+                case "<" -> compare < 0;
+                default -> false;
+            };
+            return result ? BigDecimal.ONE : BigDecimal.ZERO;
+        }
+
+        private BigDecimal additive() {
+            BigDecimal value = multiplicative();
+            while (true) {
+                skipSpace();
+                if (match("+")) {
+                    value = value.add(multiplicative());
+                } else if (match("-")) {
+                    value = value.subtract(multiplicative());
+                } else {
+                    return value;
+                }
+            }
+        }
+
+        private BigDecimal multiplicative() {
+            BigDecimal value = unary();
+            while (true) {
+                skipSpace();
+                if (match("*")) {
+                    value = value.multiply(unary());
+                } else if (match("/")) {
+                    BigDecimal divisor = unary();
+                    if (divisor.compareTo(BigDecimal.ZERO) == 0) {
+                        throw invalid("公式不能除以 0");
+                    }
+                    value = value.divide(divisor, 8, RoundingMode.HALF_UP);
+                } else {
+                    return value;
+                }
+            }
+        }
+
+        private BigDecimal unary() {
+            skipSpace();
+            if (match("+")) {
+                return unary();
+            }
+            if (match("-")) {
+                return unary().negate();
+            }
+            return primary();
+        }
+
+        private BigDecimal primary() {
+            skipSpace();
+            if (match("(")) {
+                BigDecimal value = comparison();
+                expect(")");
+                return value;
+            }
+            if (pos < input.length() && (Character.isDigit(input.charAt(pos)) || input.charAt(pos) == '.')) {
+                return number();
+            }
+            if (pos < input.length() && (Character.isLetter(input.charAt(pos)) || input.charAt(pos) == '_')) {
+                String name = identifier();
+                skipSpace();
+                if (match("(")) {
+                    return function(name);
+                }
+                BigDecimal value = variables.get(name);
+                if (value == null) {
+                    throw invalid("未知变量:" + name);
+                }
+                return value;
+            }
+            throw invalid("公式格式不正确");
+        }
+
+        private BigDecimal function(String name) {
+            List<BigDecimal> args = new ArrayList<>();
+            skipSpace();
+            if (!match(")")) {
+                do {
+                    args.add(comparison());
+                    skipSpace();
+                } while (match(","));
+                expect(")");
+            }
+            String fn = name.toUpperCase(Locale.ROOT);
+            return switch (fn) {
+                case "SUM" -> args.stream().reduce(BigDecimal.ZERO, BigDecimal::add);
+                case "MAX" -> args.stream().max(BigDecimal::compareTo).orElse(BigDecimal.ZERO);
+                case "MIN" -> args.stream().min(BigDecimal::compareTo).orElse(BigDecimal.ZERO);
+                case "ABS" -> requireCount(fn, args, 1).get(0).abs();
+                case "ROUND" -> round(requireCount(fn, args, 2));
+                case "IF" -> ifValue(requireCount(fn, args, 3));
+                default -> throw invalid("不支持的函数:" + name);
+            };
+        }
+
+        private List<BigDecimal> requireCount(String fn, List<BigDecimal> args, int count) {
+            if (args.size() != count) {
+                throw invalid(fn + " 函数需要 " + count + " 个参数");
+            }
+            return args;
+        }
+
+        private BigDecimal round(List<BigDecimal> args) {
+            int scale = args.get(1).intValue();
+            if (scale < 0 || scale > 6) {
+                throw invalid("ROUND 的小数位必须在 0 到 6 之间");
+            }
+            return args.get(0).setScale(scale, RoundingMode.HALF_UP);
+        }
+
+        private BigDecimal ifValue(List<BigDecimal> args) {
+            return args.get(0).compareTo(BigDecimal.ZERO) != 0 ? args.get(1) : args.get(2);
+        }
+
+        private BigDecimal number() {
+            int start = pos;
+            while (pos < input.length() && (Character.isDigit(input.charAt(pos)) || input.charAt(pos) == '.')) {
+                pos++;
+            }
+            try {
+                return new BigDecimal(input.substring(start, pos));
+            } catch (NumberFormatException ex) {
+                throw invalid("数字格式不正确");
+            }
+        }
+
+        private String identifier() {
+            int start = pos;
+            while (pos < input.length() && (Character.isLetterOrDigit(input.charAt(pos)) || input.charAt(pos) == '_')) {
+                pos++;
+            }
+            return input.substring(start, pos);
+        }
+
+        private void expect(String expected) {
+            if (!match(expected)) {
+                throw invalid("缺少 " + expected);
+            }
+        }
+
+        private boolean match(String text) {
+            skipSpace();
+            if (input.startsWith(text, pos)) {
+                pos += text.length();
+                return true;
+            }
+            return false;
+        }
+
+        private void skipSpace() {
+            while (pos < input.length() && Character.isWhitespace(input.charAt(pos))) {
+                pos++;
+            }
+        }
+
+        private BusinessException invalid(String message) {
+            return new BusinessException(ErrorCodes.VALIDATION_FAILED, message);
+        }
+    }
+}

+ 97 - 0
backend/src/main/java/com/example/hrlab/payroll/PayrollItemController.java

@@ -0,0 +1,97 @@
+package com.example.hrlab.payroll;
+
+import com.example.hrlab.audit.AuditService;
+import com.example.hrlab.common.ApiResponse;
+import com.example.hrlab.common.BusinessException;
+import com.example.hrlab.common.ErrorCodes;
+import com.example.hrlab.common.PageResponse;
+import com.example.hrlab.common.Rows;
+import jakarta.validation.Valid;
+import java.util.Map;
+import org.springframework.http.HttpStatus;
+import org.springframework.jdbc.core.JdbcTemplate;
+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;
+
+@RestController
+@RequestMapping("/api/v1/payroll/items")
+public class PayrollItemController {
+    private final JdbcTemplate jdbcTemplate;
+    private final AuditService auditService;
+    private final PayrollFormulaEngine formulaEngine;
+
+    public PayrollItemController(JdbcTemplate jdbcTemplate, AuditService auditService, PayrollFormulaEngine formulaEngine) {
+        this.jdbcTemplate = jdbcTemplate;
+        this.auditService = auditService;
+        this.formulaEngine = formulaEngine;
+    }
+
+    @GetMapping
+    @PreAuthorize("hasAnyRole('SYSTEM_ADMIN','PAYROLL_ADMIN','AUDITOR')")
+    public ApiResponse<PageResponse<Map<String, Object>>> list(@RequestParam(defaultValue = "1") int page,
+                                                               @RequestParam(defaultValue = "50") int size) {
+        int safePage = Math.max(page, 1);
+        int safeSize = Math.min(Math.max(size, 1), 200);
+        long total = jdbcTemplate.queryForObject("SELECT count(*) FROM payroll_items", Long.class);
+        var rows = jdbcTemplate.query("SELECT * FROM payroll_items ORDER BY item_code LIMIT ? OFFSET ?",
+                (rs, rowNum) -> Rows.map(rs), safeSize, (safePage - 1) * safeSize);
+        return ApiResponse.ok(new PageResponse<>(rows, safePage, safeSize, total));
+    }
+
+    @GetMapping("/{id}")
+    @PreAuthorize("hasAnyRole('SYSTEM_ADMIN','PAYROLL_ADMIN','AUDITOR')")
+    public ApiResponse<Map<String, Object>> get(@PathVariable Long id) {
+        return ApiResponse.ok(find(id));
+    }
+
+    @PostMapping
+    @PreAuthorize("hasAnyRole('SYSTEM_ADMIN','PAYROLL_ADMIN')")
+    public ApiResponse<Map<String, Object>> create(@Valid @RequestBody PayrollItemRequest request) {
+        formulaEngine.validate(request.formula());
+        Long id = jdbcTemplate.queryForObject("""
+                INSERT INTO payroll_items(item_code, item_name, item_type, formula)
+                VALUES (?, ?, ?, ?) RETURNING id
+                """, Long.class, request.itemCode(), request.itemName(), request.itemType(), request.formula());
+        auditService.record("PAYROLL_ITEM_CREATE", "payroll_items", id, null, request);
+        return ApiResponse.ok(find(id));
+    }
+
+    @PutMapping("/{id}")
+    @PreAuthorize("hasAnyRole('SYSTEM_ADMIN','PAYROLL_ADMIN')")
+    public ApiResponse<Map<String, Object>> update(@PathVariable Long id, @Valid @RequestBody PayrollItemRequest request) {
+        Map<String, Object> before = find(id);
+        formulaEngine.validate(request.formula());
+        jdbcTemplate.update("""
+                UPDATE payroll_items SET item_code = ?, item_name = ?, item_type = ?, formula = ?, updated_at = now()
+                WHERE id = ?
+                """, request.itemCode(), request.itemName(), request.itemType(), request.formula(), id);
+        auditService.record("PAYROLL_ITEM_UPDATE", "payroll_items", id, before, request);
+        return ApiResponse.ok(find(id));
+    }
+
+    @DeleteMapping("/{id}")
+    @PreAuthorize("hasAnyRole('SYSTEM_ADMIN','PAYROLL_ADMIN')")
+    public ApiResponse<Map<String, Object>> delete(@PathVariable Long id) {
+        Map<String, Object> before = find(id);
+        jdbcTemplate.update("DELETE FROM payroll_items WHERE id = ?", id);
+        auditService.record("PAYROLL_ITEM_DELETE", "payroll_items", id, before, Map.of("deleted", true));
+        return ApiResponse.ok(Map.of("deleted", true));
+    }
+
+    private Map<String, Object> find(Long id) {
+        return jdbcTemplate.query("SELECT * FROM payroll_items WHERE id = ?", rs -> {
+            if (!rs.next()) {
+                throw new BusinessException(ErrorCodes.NOT_FOUND, "payroll item not found", HttpStatus.NOT_FOUND);
+            }
+            return Rows.map(rs);
+        }, id);
+    }
+}

+ 7 - 0
backend/src/main/java/com/example/hrlab/payroll/PayrollItemRequest.java

@@ -0,0 +1,7 @@
+package com.example.hrlab.payroll;
+
+import jakarta.validation.constraints.NotBlank;
+
+public record PayrollItemRequest(@NotBlank String itemCode, @NotBlank String itemName,
+                                 @NotBlank String itemType, String formula) {
+}

+ 9 - 0
backend/src/main/java/com/example/hrlab/payroll/PayrollPeriodRequest.java

@@ -0,0 +1,9 @@
+package com.example.hrlab.payroll;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import java.time.LocalDate;
+
+public record PayrollPeriodRequest(@NotBlank String periodKey, @NotNull LocalDate startDate,
+                                   @NotNull LocalDate endDate, String status) {
+}

+ 6 - 0
backend/src/main/java/com/example/hrlab/payroll/PayrollRunRequest.java

@@ -0,0 +1,6 @@
+package com.example.hrlab.payroll;
+
+import java.util.List;
+
+public record PayrollRunRequest(boolean recalculate, List<Long> includeDepartments, String formulaVersion) {
+}

+ 407 - 0
backend/src/main/java/com/example/hrlab/payroll/PayrollService.java

@@ -0,0 +1,407 @@
+package com.example.hrlab.payroll;
+
+import com.example.hrlab.audit.AuditService;
+import com.example.hrlab.common.AsyncTaskPublisher;
+import com.example.hrlab.common.BusinessException;
+import com.example.hrlab.common.ErrorCodes;
+import com.example.hrlab.common.JsonUtils;
+import com.example.hrlab.config.AppProperties;
+import com.example.hrlab.integration.IntegrationEventService;
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.time.Clock;
+import java.time.Duration;
+import java.time.LocalDate;
+import java.time.OffsetDateTime;
+import java.time.temporal.ChronoUnit;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.support.TransactionTemplate;
+
+@Service
+public class PayrollService {
+    private final JdbcTemplate jdbcTemplate;
+    private final TransactionTemplate transactionTemplate;
+    private final PayrollCalculator calculator;
+    private final PayrollFormulaEngine formulaEngine;
+    private final AppProperties properties;
+    private final Clock clock;
+    private final AuditService auditService;
+    private final IntegrationEventService integrationEventService;
+    private final AsyncTaskPublisher taskPublisher;
+
+    public PayrollService(JdbcTemplate jdbcTemplate, TransactionTemplate transactionTemplate, PayrollCalculator calculator,
+                          PayrollFormulaEngine formulaEngine,
+                          AppProperties properties, Clock clock, AuditService auditService,
+                          IntegrationEventService integrationEventService, AsyncTaskPublisher taskPublisher) {
+        this.jdbcTemplate = jdbcTemplate;
+        this.transactionTemplate = transactionTemplate;
+        this.calculator = calculator;
+        this.formulaEngine = formulaEngine;
+        this.properties = properties;
+        this.clock = clock;
+        this.auditService = auditService;
+        this.integrationEventService = integrationEventService;
+        this.taskPublisher = taskPublisher;
+    }
+
+    public Long submitRun(String periodKey, PayrollRunRequest request) {
+        Map<String, Object> period = jdbcTemplate.queryForMap("SELECT * FROM payroll_periods WHERE period_key = ?", periodKey);
+        if ("LOCKED".equals(period.get("status"))) {
+            throw new BusinessException(ErrorCodes.BUSINESS_RULE_VIOLATION, "薪酬期间已锁定");
+        }
+        Integer nextRunNo = jdbcTemplate.queryForObject("""
+                SELECT COALESCE(max(run_no), 0) + 1 FROM payroll_runs WHERE payroll_period_id = ?
+                """, Integer.class, period.get("id"));
+        Long runId = jdbcTemplate.queryForObject("""
+                INSERT INTO payroll_runs(payroll_period_id, run_no, formula_version, status, started_at)
+                VALUES (?, ?, ?, 'QUEUED', ?) RETURNING id
+                """, Long.class, period.get("id"), nextRunNo, request.formulaVersion() == null ? "v1.0.0" : request.formulaVersion(),
+                OffsetDateTime.now(clock));
+        auditService.record("PAYROLL_RUN_SUBMIT", "payroll_runs", runId, null, request);
+        if (properties.tasks().inline()) {
+            processRun(runId, request.includeDepartments());
+        } else {
+            taskPublisher.publish("payroll", String.valueOf(runId));
+        }
+        return runId;
+    }
+
+    public void processRun(Long runId, List<Long> departments) {
+        try {
+            if (properties.lab().failpoint().payrollExtraDelayMs() > 0) {
+                Thread.sleep(properties.lab().failpoint().payrollExtraDelayMs());
+            }
+            transactionTemplate.executeWithoutResult(status -> calculateInsideTransaction(runId, departments));
+            jdbcTemplate.update("UPDATE payroll_runs SET status = 'CALCULATED', finished_at = ?, error_message = NULL, version = version + 1, updated_at = now() WHERE id = ?",
+                    OffsetDateTime.now(clock), runId);
+        } catch (Exception ex) {
+            jdbcTemplate.update("UPDATE payroll_runs SET status = 'FAILED', finished_at = ?, error_message = ?, version = version + 1, updated_at = now() WHERE id = ?",
+                    OffsetDateTime.now(clock), ex.getMessage(), runId);
+        }
+    }
+
+    private void calculateInsideTransaction(Long runId, List<Long> departments) {
+        Map<String, Object> run = jdbcTemplate.queryForMap("""
+                SELECT pr.*, pp.start_date, pp.end_date FROM payroll_runs pr
+                JOIN payroll_periods pp ON pp.id = pr.payroll_period_id
+                WHERE pr.id = ?
+                """, runId);
+        LocalDate start = ((java.sql.Date) run.get("start_date")).toLocalDate();
+        LocalDate end = ((java.sql.Date) run.get("end_date")).toLocalDate();
+        jdbcTemplate.update("DELETE FROM payslips WHERE payroll_run_id = ?", runId);
+        List<Map<String, Object>> payrollItems = jdbcTemplate.queryForList("SELECT * FROM payroll_items ORDER BY item_code");
+        String deptSql = departments == null || departments.isEmpty() ? "" : " AND department_id IN (" + departments.stream().map(String::valueOf).reduce((a, b) -> a + "," + b).orElse("") + ")";
+        List<Map<String, Object>> employees = jdbcTemplate.queryForList("""
+                SELECT id, department_id, leave_date FROM employees
+                WHERE employment_status IN ('ACTIVE','LEFT') AND archived = false
+                  AND (leave_date IS NULL OR leave_date >= ?)
+                """ + deptSql, start);
+        int employeeCount = 0;
+        BigDecimal totalGross = BigDecimal.ZERO;
+        BigDecimal totalDeduction = BigDecimal.ZERO;
+        BigDecimal totalNet = BigDecimal.ZERO;
+        for (Map<String, Object> employee : employees) {
+            Long employeeId = ((Number) employee.get("id")).longValue();
+            Long departmentId = ((Number) employee.get("department_id")).longValue();
+            BigDecimal ratio = prorateRatio(start, end, (java.sql.Date) employee.get("leave_date"));
+            long totalDays = ChronoUnit.DAYS.between(start, end.plusDays(1));
+            long paidDays = paidDays(start, end, (java.sql.Date) employee.get("leave_date"));
+            CalculationResult calculation = calculateAmountWithItems(payrollItems, employeeId, departmentId, start, end, ratio, paidDays, totalDays);
+            PayrollCalculator.PayslipAmount amount = calculation.amount();
+            List<Map<String, Object>> detail = calculateLines(calculation.lines(), amount);
+            jdbcTemplate.update("""
+                    INSERT INTO payslips(payroll_run_id, employee_id, base_salary, allowance_total, attendance_deduction,
+                        performance_bonus, manual_adjustment, deduction_total, net_amount, detail_jsonb)
+                    VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?::jsonb)
+                    """, runId, employeeId, amount.baseSalary(), amount.allowanceTotal(), amount.attendanceDeduction(),
+                    amount.performanceBonus(), amount.manualAdjustment(), amount.deductionTotal(), amount.netAmount(),
+                    JsonUtils.toJson(detail));
+            employeeCount++;
+            totalGross = totalGross.add(amount.baseSalary()).add(amount.allowanceTotal()).add(amount.performanceBonus()).add(amount.manualAdjustment());
+            totalDeduction = totalDeduction.add(amount.deductionTotal());
+            totalNet = totalNet.add(amount.netAmount());
+        }
+        Map<String, Object> summary = new LinkedHashMap<>();
+        summary.put("employeeCount", employeeCount);
+        summary.put("totalGross", money(totalGross));
+        summary.put("totalDeduction", money(totalDeduction));
+        summary.put("totalNet", money(totalNet));
+        summary.put("formulaItemCount", payrollItems.size());
+        summary.put("periodStart", start);
+        summary.put("periodEnd", end);
+        jdbcTemplate.update("UPDATE payroll_runs SET summary_jsonb = ?::jsonb, updated_at = now() WHERE id = ?",
+                JsonUtils.toJson(summary), runId);
+    }
+
+    private BigDecimal prorateRatio(LocalDate start, LocalDate end, java.sql.Date leaveDateSql) {
+        if (leaveDateSql == null) {
+            return BigDecimal.ONE;
+        }
+        LocalDate leaveDate = leaveDateSql.toLocalDate();
+        if (leaveDate.isAfter(end) || leaveDate.isEqual(end)) {
+            return BigDecimal.ONE;
+        }
+        long totalDays = ChronoUnit.DAYS.between(start, end.plusDays(1));
+        long paidDays = Math.max(0, ChronoUnit.DAYS.between(start, leaveDate.plusDays(1)));
+        return BigDecimal.valueOf(paidDays).divide(BigDecimal.valueOf(totalDays), 6, RoundingMode.HALF_UP);
+    }
+
+    private long paidDays(LocalDate start, LocalDate end, java.sql.Date leaveDateSql) {
+        if (leaveDateSql == null) {
+            return ChronoUnit.DAYS.between(start, end.plusDays(1));
+        }
+        LocalDate leaveDate = leaveDateSql.toLocalDate();
+        if (leaveDate.isAfter(end) || leaveDate.isEqual(end)) {
+            return ChronoUnit.DAYS.between(start, end.plusDays(1));
+        }
+        return Math.max(0, ChronoUnit.DAYS.between(start, leaveDate.plusDays(1)));
+    }
+
+    private CalculationResult calculateAmountWithItems(List<Map<String, Object>> payrollItems,
+                                                       Long employeeId,
+                                                       Long departmentId,
+                                                       LocalDate start,
+                                                       LocalDate end,
+                                                       BigDecimal ratio,
+                                                       long paidDays,
+                                                       long totalDays) {
+        calculator.verifyFormulaAvailable();
+        Map<String, BigDecimal> variables = variables(employeeId, departmentId, start, end, ratio, paidDays, totalDays);
+        List<LineAmount> lineAmounts = evaluateItems(payrollItems, variables);
+        BigDecimal base = amountByCode(lineAmounts, "BASE", variables.getOrDefault("proratedBaseSalary", BigDecimal.ZERO));
+        BigDecimal performance = amountByCode(lineAmounts, "PERF", variables.getOrDefault("performanceBonus", BigDecimal.ZERO));
+        BigDecimal manual = amountByCode(lineAmounts, "MANUAL", variables.getOrDefault("manualAdjustment", BigDecimal.ZERO));
+        BigDecimal attendanceDeduction = amountByCode(lineAmounts, "ATT_DEDUCT", variables.getOrDefault("attendanceDeduction", BigDecimal.ZERO));
+        BigDecimal allowance = sumByType(lineAmounts, "EARNING", List.of("BASE", "PERF", "MANUAL", "NET"));
+        BigDecimal deduction = lineAmounts.stream().anyMatch(line -> "DEDUCTION_TOTAL".equals(line.code()))
+                ? amountByCode(lineAmounts, "DEDUCTION_TOTAL", variables.getOrDefault("deductionTotal", BigDecimal.ZERO))
+                : sumByType(lineAmounts, "DEDUCTION", List.of("DEDUCTION_TOTAL"));
+        BigDecimal net = amountByCode(lineAmounts, "NET", base.add(allowance).add(performance).add(manual).subtract(deduction));
+        PayrollCalculator.PayslipAmount amount = new PayrollCalculator.PayslipAmount(money(base), money(allowance),
+                money(attendanceDeduction), money(performance), money(manual), money(deduction), money(net));
+        return new CalculationResult(amount, lineAmounts);
+    }
+
+    private List<Map<String, Object>> calculateLines(List<LineAmount> lineAmounts, PayrollCalculator.PayslipAmount amount) {
+        List<Map<String, Object>> lines = new ArrayList<>();
+        for (LineAmount line : lineAmounts) {
+            Map<String, Object> item = new LinkedHashMap<>();
+            item.put("code", line.code());
+            item.put("name", line.name());
+            item.put("type", line.type());
+            item.put("formula", line.formula());
+            item.put("amount", money(line.amount()));
+            lines.add(item);
+        }
+        addSystemLine(lines, "DEDUCTION_TOTAL", "扣减合计", "SUMMARY", amount.deductionTotal());
+        addSystemLine(lines, "NET", "实发金额", "SUMMARY", amount.netAmount());
+        return lines;
+    }
+
+    private List<LineAmount> evaluateItems(List<Map<String, Object>> payrollItems, Map<String, BigDecimal> variables) {
+        List<LineAmount> results = new ArrayList<>();
+        for (Map<String, Object> item : payrollItems) {
+            String code = String.valueOf(item.get("item_code"));
+            String name = String.valueOf(item.get("item_name"));
+            String type = String.valueOf(item.get("item_type"));
+            String formula = item.get("formula") == null ? defaultFormula(code) : String.valueOf(item.get("formula"));
+            BigDecimal amount = formulaEngine.evaluate(formula, variables);
+            results.add(new LineAmount(code, name, type, formula, amount));
+        }
+        return results;
+    }
+
+    private String defaultFormula(String code) {
+        return switch (code) {
+            case "BASE" -> "baseSalary";
+            case "ALLOWANCE" -> "allowanceTotal";
+            case "ATT_DEDUCT" -> "attendanceDeduction";
+            case "PERF" -> "performanceBonus";
+            case "MANUAL" -> "manualAdjustment";
+            default -> "0";
+        };
+    }
+
+    private Map<String, BigDecimal> variables(Long employeeId, Long departmentId, LocalDate start, LocalDate end,
+                                              BigDecimal ratio, long paidDays, long totalDays) {
+        Map<String, BigDecimal> variables = new HashMap<>();
+        variables.putAll(resolveConfiguredVariables(employeeId, departmentId));
+        AttendanceFacts facts = attendanceFacts(employeeId, start, end, totalDays);
+        variables.putIfAbsent("workHours", facts.workHours());
+        variables.putIfAbsent("attendanceDays", BigDecimal.valueOf(facts.attendanceDays()));
+        variables.putIfAbsent("absenceDays", BigDecimal.valueOf(facts.absenceDays()));
+        variables.put("anomalyCount", BigDecimal.valueOf(facts.anomalyCount()));
+        variables.put("paidDays", BigDecimal.valueOf(paidDays));
+        variables.put("totalDays", BigDecimal.valueOf(totalDays));
+        variables.put("prorateRatio", ratio);
+        BigDecimal baseSalary = variables.getOrDefault("baseSalary", BigDecimal.ZERO);
+        variables.put("standardBaseSalary", baseSalary);
+        variables.put("proratedBaseSalary", money(baseSalary.multiply(ratio)));
+        variables.putIfAbsent("allowanceTotal", money(
+                variables.getOrDefault("highTemperatureDays", BigDecimal.ZERO).multiply(variables.getOrDefault("highTemperatureAllowanceRate", BigDecimal.ZERO))
+                        .add(variables.getOrDefault("specialEnvironmentDays", BigDecimal.ZERO).multiply(variables.getOrDefault("specialEnvironmentAllowanceRate", BigDecimal.ZERO)))
+                        .add(variables.getOrDefault("holidayDays", BigDecimal.ZERO).multiply(variables.getOrDefault("holidayAllowanceRate", BigDecimal.ZERO)))
+                        .add(variables.getOrDefault("businessTripDays", BigDecimal.ZERO).multiply(variables.getOrDefault("businessTripAllowanceRate", BigDecimal.ZERO)))));
+        variables.putIfAbsent("attendanceDeduction", variables.get("anomalyCount").multiply(variables.getOrDefault("attendanceDeductionPerAnomaly", BigDecimal.ZERO)));
+        variables.putIfAbsent("performanceBonus", variables.getOrDefault("monthlyPerformance", BigDecimal.ZERO).multiply(variables.getOrDefault("performanceBonusRate", BigDecimal.ZERO)));
+        variables.putIfAbsent("manualAdjustment", variables.getOrDefault("extraRewardPenalty", BigDecimal.ZERO));
+        variables.putIfAbsent("deductionTotal", variables.getOrDefault("attendanceDeduction", BigDecimal.ZERO).max(BigDecimal.ZERO));
+        variables.putIfAbsent("netAmount", variables.get("proratedBaseSalary")
+                .add(variables.getOrDefault("allowanceTotal", BigDecimal.ZERO))
+                .add(variables.getOrDefault("performanceBonus", BigDecimal.ZERO))
+                .add(variables.getOrDefault("manualAdjustment", BigDecimal.ZERO))
+                .subtract(variables.getOrDefault("deductionTotal", BigDecimal.ZERO)));
+        return variables;
+    }
+
+    private Map<String, BigDecimal> resolveConfiguredVariables(Long employeeId, Long departmentId) {
+        Map<String, BigDecimal> variables = new HashMap<>();
+        List<Map<String, Object>> departmentVariables = jdbcTemplate.queryForList("""
+                WITH RECURSIVE dept_tree AS (
+                    SELECT id, parent_id, 0 depth FROM departments WHERE id = ?
+                    UNION ALL
+                    SELECT d.id, d.parent_id, dt.depth + 1 FROM departments d
+                    JOIN dept_tree dt ON dt.parent_id = d.id
+                )
+                SELECT pv.variable_key, pv.variable_value
+                FROM dept_tree dt
+                JOIN payroll_variables pv ON pv.scope_type = 'DEPARTMENT' AND pv.department_id = dt.id
+                ORDER BY dt.depth DESC, pv.variable_key
+                """, departmentId);
+        for (Map<String, Object> variable : departmentVariables) {
+            variables.put(String.valueOf(variable.get("variable_key")), (BigDecimal) variable.get("variable_value"));
+        }
+        List<Map<String, Object>> employeeVariables = jdbcTemplate.queryForList("""
+                SELECT variable_key, variable_value FROM payroll_variables
+                WHERE scope_type = 'EMPLOYEE' AND employee_id = ?
+                ORDER BY variable_key
+                """, employeeId);
+        for (Map<String, Object> variable : employeeVariables) {
+            variables.put(String.valueOf(variable.get("variable_key")), (BigDecimal) variable.get("variable_value"));
+        }
+        return variables;
+    }
+
+    private AttendanceFacts attendanceFacts(Long employeeId, LocalDate start, LocalDate end, long totalDays) {
+        List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
+                SELECT clock_in_at, clock_out_at, anomaly_status FROM attendance_records
+                WHERE employee_id = ? AND work_date BETWEEN ? AND ?
+                """, employeeId, start, end);
+        BigDecimal workHours = BigDecimal.ZERO;
+        int attendanceDays = 0;
+        int anomalies = 0;
+        for (Map<String, Object> row : rows) {
+            if (!"NORMAL".equals(row.get("anomaly_status"))) {
+                anomalies++;
+            }
+            OffsetDateTime in = toOffsetDateTime(row.get("clock_in_at"));
+            OffsetDateTime out = toOffsetDateTime(row.get("clock_out_at"));
+            if (in != null && out != null) {
+                attendanceDays++;
+                workHours = workHours.add(BigDecimal.valueOf(Duration.between(in, out).toMinutes())
+                        .divide(new BigDecimal("60"), 2, RoundingMode.HALF_UP));
+            }
+        }
+        long absenceDays = Math.max(0, totalDays - attendanceDays);
+        return new AttendanceFacts(workHours, attendanceDays, absenceDays, anomalies);
+    }
+
+    private OffsetDateTime toOffsetDateTime(Object value) {
+        if (value instanceof OffsetDateTime offsetDateTime) {
+            return offsetDateTime;
+        }
+        if (value instanceof java.sql.Timestamp timestamp) {
+            return timestamp.toInstant().atOffset(clock.getZone().getRules().getOffset(timestamp.toInstant()));
+        }
+        return null;
+    }
+
+    private BigDecimal amountByCode(List<LineAmount> lines, String code, BigDecimal fallback) {
+        return lines.stream().filter(line -> code.equals(line.code())).map(LineAmount::amount).findFirst().orElse(fallback);
+    }
+
+    private BigDecimal sumByType(List<LineAmount> lines, String type, List<String> excludedCodes) {
+        return lines.stream()
+                .filter(line -> type.equals(line.type()))
+                .filter(line -> !excludedCodes.contains(line.code()))
+                .map(LineAmount::amount)
+                .reduce(BigDecimal.ZERO, BigDecimal::add);
+    }
+
+    private void addSystemLine(List<Map<String, Object>> lines, String code, String name, String type, BigDecimal amount) {
+        Map<String, Object> item = new LinkedHashMap<>();
+        item.put("code", code);
+        item.put("name", name);
+        item.put("type", type);
+        item.put("formula", "");
+        item.put("amount", money(amount));
+        lines.add(item);
+    }
+
+    private BigDecimal money(BigDecimal value) {
+        return value.setScale(2, RoundingMode.HALF_UP);
+    }
+
+    private record LineAmount(String code, String name, String type, String formula, BigDecimal amount) {
+    }
+
+    private record CalculationResult(PayrollCalculator.PayslipAmount amount, List<LineAmount> lines) {
+    }
+
+    private record AttendanceFacts(BigDecimal workHours, int attendanceDays, long absenceDays, int anomalyCount) {
+    }
+
+    public void transitionRun(Long id, String targetStatus, String action) {
+        Map<String, Object> before = jdbcTemplate.queryForMap("SELECT * FROM payroll_runs WHERE id = ?", id);
+        if ("LOCKED".equals(before.get("status"))) {
+            throw new BusinessException(ErrorCodes.BUSINESS_RULE_VIOLATION, "已锁定批次不得修改");
+        }
+        jdbcTemplate.update("UPDATE payroll_runs SET status = ?, version = version + 1, updated_at = now() WHERE id = ?",
+                targetStatus, id);
+        auditService.record(action, "payroll_runs", id, before, Map.of("status", targetStatus));
+    }
+
+    public void publishPayslip(Long id) {
+        Map<String, Object> before = jdbcTemplate.queryForMap("SELECT * FROM payslips WHERE id = ?", id);
+        Long runId = ((Number) before.get("payroll_run_id")).longValue();
+        String status = jdbcTemplate.queryForObject("SELECT status FROM payroll_runs WHERE id = ?", String.class, runId);
+        if (!"LOCKED".equals(status) && !"APPROVED".equals(status)) {
+            throw new BusinessException(ErrorCodes.BUSINESS_RULE_VIOLATION, "工资批次未批准或锁定,不能发布工资单");
+        }
+        jdbcTemplate.update("UPDATE payslips SET published_at = ?, updated_at = now() WHERE id = ?", OffsetDateTime.now(clock), id);
+        integrationEventService.createEvent("PAYSLIP_PUBLISHED", "payslip:" + id, Map.of("payslipId", id));
+        auditService.record("PAYSLIP_PUBLISH", "payslips", id, before, Map.of("published", true));
+    }
+
+    public int publishPayslips(String periodKey, Long runId) {
+        List<Object> params = new ArrayList<>();
+        List<String> conditions = new ArrayList<>();
+        conditions.add("ps.published_at IS NULL");
+        conditions.add("pr.status IN ('APPROVED', 'LOCKED')");
+        if (periodKey != null && !periodKey.isBlank()) {
+            conditions.add("pp.period_key = ?");
+            params.add(periodKey);
+        }
+        if (runId != null) {
+            conditions.add("ps.payroll_run_id = ?");
+            params.add(runId);
+        }
+        List<Long> ids = jdbcTemplate.queryForList("""
+                SELECT ps.id FROM payslips ps
+                JOIN payroll_runs pr ON pr.id = ps.payroll_run_id
+                JOIN payroll_periods pp ON pp.id = pr.payroll_period_id
+                WHERE """ + String.join(" AND ", conditions) + " ORDER BY ps.id", Long.class, params.toArray());
+        return transactionTemplate.execute(status -> {
+            for (Long id : ids) {
+                publishPayslip(id);
+            }
+            return ids.size();
+        });
+    }
+}

+ 129 - 0
backend/src/main/java/com/example/hrlab/payroll/PayrollVariableController.java

@@ -0,0 +1,129 @@
+package com.example.hrlab.payroll;
+
+import com.example.hrlab.audit.AuditService;
+import com.example.hrlab.common.ApiResponse;
+import com.example.hrlab.common.BusinessException;
+import com.example.hrlab.common.ErrorCodes;
+import jakarta.validation.Valid;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.web.bind.annotation.GetMapping;
+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;
+
+@RestController
+@RequestMapping("/api/v1/payroll/variables")
+public class PayrollVariableController {
+    private final JdbcTemplate jdbcTemplate;
+    private final AuditService auditService;
+
+    public PayrollVariableController(JdbcTemplate jdbcTemplate, AuditService auditService) {
+        this.jdbcTemplate = jdbcTemplate;
+        this.auditService = auditService;
+    }
+
+    @GetMapping
+    @PreAuthorize("hasAnyRole('SYSTEM_ADMIN','PAYROLL_ADMIN','AUDITOR')")
+    public ApiResponse<Object> list(@RequestParam(required = false) String scopeType,
+                                    @RequestParam(required = false) Long departmentId,
+                                    @RequestParam(required = false) Long employeeId) {
+        List<Object> args = new ArrayList<>();
+        List<String> conditions = new ArrayList<>();
+        if (scopeType != null && !scopeType.isBlank()) {
+            conditions.add("pv.scope_type = ?");
+            args.add(scopeType.toUpperCase());
+        }
+        if (departmentId != null) {
+            conditions.add("pv.department_id = ?");
+            args.add(departmentId);
+        }
+        if (employeeId != null) {
+            conditions.add("pv.employee_id = ?");
+            args.add(employeeId);
+        }
+        String where = conditions.isEmpty() ? "" : " WHERE " + String.join(" AND ", conditions);
+        return ApiResponse.ok(jdbcTemplate.queryForList("""
+                SELECT pv.*, d.dept_name, e.employee_no, e.display_name
+                FROM payroll_variables pv
+                LEFT JOIN departments d ON d.id = pv.department_id
+                LEFT JOIN employees e ON e.id = pv.employee_id
+                """ + where + " ORDER BY pv.scope_type, d.path, e.employee_no, pv.variable_key", args.toArray()));
+    }
+
+    @GetMapping("/effective")
+    @PreAuthorize("hasAnyRole('SYSTEM_ADMIN','PAYROLL_ADMIN','AUDITOR')")
+    public ApiResponse<Object> effective(@RequestParam Long employeeId) {
+        return ApiResponse.ok(resolveEffectiveRows(employeeId));
+    }
+
+    @PutMapping
+    @PreAuthorize("hasAnyRole('SYSTEM_ADMIN','PAYROLL_ADMIN')")
+    @Transactional
+    public ApiResponse<Object> replace(@Valid @RequestBody PayrollVariableRequest request) {
+        String scope = request.scopeType().toUpperCase();
+        validateScope(scope, request.departmentId(), request.employeeId());
+        Object before = list(scope, request.departmentId(), request.employeeId()).data();
+        if ("DEPARTMENT".equals(scope)) {
+            jdbcTemplate.update("DELETE FROM payroll_variables WHERE scope_type = 'DEPARTMENT' AND department_id = ?", request.departmentId());
+        } else {
+            jdbcTemplate.update("DELETE FROM payroll_variables WHERE scope_type = 'EMPLOYEE' AND employee_id = ?", request.employeeId());
+        }
+        for (PayrollVariableRequest.VariableLine line : request.variables()) {
+            if (line.variableValue() == null) {
+                throw new BusinessException(ErrorCodes.VALIDATION_FAILED, "变量值不能为空");
+            }
+            jdbcTemplate.update("""
+                    INSERT INTO payroll_variables(scope_type, department_id, employee_id, variable_key, variable_name, variable_value)
+                    VALUES (?, ?, ?, ?, ?, ?)
+                    """, scope, "DEPARTMENT".equals(scope) ? request.departmentId() : null,
+                    "EMPLOYEE".equals(scope) ? request.employeeId() : null,
+                    line.variableKey(), line.variableName(), line.variableValue());
+        }
+        Object after = list(scope, request.departmentId(), request.employeeId()).data();
+        auditService.record("PAYROLL_VARIABLE_REPLACE", "payroll_variables", scope, before, after);
+        return ApiResponse.ok(after);
+    }
+
+    private List<Map<String, Object>> resolveEffectiveRows(Long employeeId) {
+        Long departmentId = jdbcTemplate.queryForObject("SELECT department_id FROM employees WHERE id = ?", Long.class, employeeId);
+        List<Map<String, Object>> rows = new ArrayList<>();
+        rows.addAll(jdbcTemplate.queryForList("""
+                WITH RECURSIVE dept_tree AS (
+                    SELECT id, parent_id, 0 depth FROM departments WHERE id = ?
+                    UNION ALL
+                    SELECT d.id, d.parent_id, dt.depth + 1 FROM departments d
+                    JOIN dept_tree dt ON dt.parent_id = d.id
+                )
+                SELECT pv.*, d.dept_name, NULL AS employee_no, NULL AS display_name
+                FROM dept_tree dt
+                JOIN payroll_variables pv ON pv.scope_type = 'DEPARTMENT' AND pv.department_id = dt.id
+                JOIN departments d ON d.id = pv.department_id
+                ORDER BY dt.depth DESC, pv.variable_key
+                """, departmentId));
+        rows.addAll(jdbcTemplate.queryForList("""
+                SELECT pv.*, NULL AS dept_name, e.employee_no, e.display_name
+                FROM payroll_variables pv
+                JOIN employees e ON e.id = pv.employee_id
+                WHERE pv.scope_type = 'EMPLOYEE' AND pv.employee_id = ?
+                ORDER BY pv.variable_key
+                """, employeeId));
+        return rows;
+    }
+
+    private void validateScope(String scope, Long departmentId, Long employeeId) {
+        if ("DEPARTMENT".equals(scope) && departmentId != null && employeeId == null) {
+            return;
+        }
+        if ("EMPLOYEE".equals(scope) && employeeId != null && departmentId == null) {
+            return;
+        }
+        throw new BusinessException(ErrorCodes.VALIDATION_FAILED, "变量范围必须是部门或员工,且只能选择对应对象");
+    }
+}

+ 13 - 0
backend/src/main/java/com/example/hrlab/payroll/PayrollVariableRequest.java

@@ -0,0 +1,13 @@
+package com.example.hrlab.payroll;
+
+import jakarta.validation.Valid;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotEmpty;
+import java.math.BigDecimal;
+import java.util.List;
+
+public record PayrollVariableRequest(@NotBlank String scopeType, Long departmentId, Long employeeId,
+                                     @Valid @NotEmpty List<VariableLine> variables) {
+    public record VariableLine(@NotBlank String variableKey, @NotBlank String variableName, BigDecimal variableValue) {
+    }
+}

+ 4 - 0
backend/src/main/java/com/example/hrlab/payroll/PayslipBatchPublishRequest.java

@@ -0,0 +1,4 @@
+package com.example.hrlab.payroll;
+
+public record PayslipBatchPublishRequest(String periodKey, Long runId) {
+}

+ 9 - 0
backend/src/main/java/com/example/hrlab/performance/AppraisalCycleRequest.java

@@ -0,0 +1,9 @@
+package com.example.hrlab.performance;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import java.time.LocalDate;
+
+public record AppraisalCycleRequest(@NotBlank String cycleName, @NotNull LocalDate startDate,
+                                    @NotNull LocalDate endDate, String status, Object template) {
+}

+ 9 - 0
backend/src/main/java/com/example/hrlab/performance/AppraisalRecordRequest.java

@@ -0,0 +1,9 @@
+package com.example.hrlab.performance;
+
+import jakarta.validation.constraints.NotNull;
+import java.math.BigDecimal;
+
+public record AppraisalRecordRequest(@NotNull Long cycleId, @NotNull Long employeeId, Long managerId,
+                                     Object goals, BigDecimal selfScore, BigDecimal managerScore,
+                                     BigDecimal finalScore, String finalGrade, String status) {
+}

+ 37 - 0
backend/src/main/java/com/example/hrlab/performance/AppraisalValidator.java

@@ -0,0 +1,37 @@
+package com.example.hrlab.performance;
+
+import com.example.hrlab.common.BusinessException;
+import com.example.hrlab.common.ErrorCodes;
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import java.math.BigDecimal;
+import java.util.List;
+import java.util.Map;
+import org.springframework.stereotype.Service;
+
+@Service
+public class AppraisalValidator {
+    private final ObjectMapper objectMapper;
+
+    public AppraisalValidator(ObjectMapper objectMapper) {
+        this.objectMapper = objectMapper;
+    }
+
+    public void validateTemplate(Object template) {
+        if (template == null) {
+            return;
+        }
+        Map<String, Object> map = objectMapper.convertValue(template, new TypeReference<>() {});
+        Object goals = map.get("goals");
+        if (goals == null) {
+            return;
+        }
+        List<Map<String, Object>> items = objectMapper.convertValue(goals, new TypeReference<>() {});
+        BigDecimal total = items.stream()
+                .map(item -> new BigDecimal(String.valueOf(item.getOrDefault("weight", "0"))))
+                .reduce(BigDecimal.ZERO, BigDecimal::add);
+        if (total.compareTo(new BigDecimal("100")) != 0) {
+            throw new BusinessException(ErrorCodes.BUSINESS_RULE_VIOLATION, "绩效模板权重总和必须等于 100");
+        }
+    }
+}

+ 181 - 0
backend/src/main/java/com/example/hrlab/performance/PerformanceController.java

@@ -0,0 +1,181 @@
+package com.example.hrlab.performance;
+
+import com.example.hrlab.audit.AuditService;
+import com.example.hrlab.common.ApiResponse;
+import com.example.hrlab.common.BusinessException;
+import com.example.hrlab.common.ErrorCodes;
+import com.example.hrlab.common.JsonUtils;
+import com.example.hrlab.common.PageResponse;
+import com.example.hrlab.common.Rows;
+import com.example.hrlab.security.DataScopeService;
+import jakarta.validation.Valid;
+import java.time.LocalDate;
+import java.util.Map;
+import org.springframework.http.HttpStatus;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.security.access.prepost.PreAuthorize;
+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.DeleteMapping;
+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;
+
+@RestController
+@RequestMapping("/api/v1")
+public class PerformanceController {
+    private final JdbcTemplate jdbcTemplate;
+    private final AuditService auditService;
+    private final AppraisalValidator validator;
+    private final DataScopeService dataScopeService;
+
+    public PerformanceController(JdbcTemplate jdbcTemplate, AuditService auditService,
+                                 AppraisalValidator validator, DataScopeService dataScopeService) {
+        this.jdbcTemplate = jdbcTemplate;
+        this.auditService = auditService;
+        this.validator = validator;
+        this.dataScopeService = dataScopeService;
+    }
+
+    @GetMapping("/appraisal-cycles")
+    @PreAuthorize("isAuthenticated()")
+    public ApiResponse<PageResponse<Map<String, Object>>> cycles(@RequestParam(defaultValue = "1") int page,
+                                                                 @RequestParam(defaultValue = "20") int size) {
+        return ApiResponse.ok(page("appraisal_cycles", "id DESC", page, size));
+    }
+
+    @PostMapping("/appraisal-cycles")
+    @PreAuthorize("hasAnyRole('SYSTEM_ADMIN','HR_ADMIN','DEPT_MANAGER')")
+    public ApiResponse<Map<String, Object>> createCycle(@Valid @RequestBody AppraisalCycleRequest request) {
+        validateDates(request.startDate(), request.endDate());
+        validator.validateTemplate(request.template());
+        Long id = jdbcTemplate.queryForObject("""
+                INSERT INTO appraisal_cycles(cycle_name, start_date, end_date, status, template_jsonb)
+                VALUES (?, ?, ?, ?, ?::jsonb) RETURNING id
+                """, Long.class, request.cycleName(), request.startDate(), request.endDate(),
+                request.status() == null ? "DRAFT" : request.status(),
+                JsonUtils.toJson(request.template() == null ? Map.of() : request.template()));
+        auditService.record("APPRAISAL_CYCLE_CREATE", "appraisal_cycles", id, null, request);
+        return ApiResponse.ok(find("appraisal_cycles", id, "绩效周期不存在"));
+    }
+
+    @PutMapping("/appraisal-cycles/{id}")
+    @PreAuthorize("hasAnyRole('SYSTEM_ADMIN','HR_ADMIN','DEPT_MANAGER')")
+    public ApiResponse<Map<String, Object>> updateCycle(@PathVariable Long id, @Valid @RequestBody AppraisalCycleRequest request) {
+        Map<String, Object> before = find("appraisal_cycles", id, "绩效周期不存在");
+        requireNotClosed(before);
+        validateDates(request.startDate(), request.endDate());
+        validator.validateTemplate(request.template());
+        jdbcTemplate.update("""
+                UPDATE appraisal_cycles SET cycle_name = ?, start_date = ?, end_date = ?, status = ?,
+                    template_jsonb = ?::jsonb, version = version + 1, updated_at = now()
+                WHERE id = ?
+                """, request.cycleName(), request.startDate(), request.endDate(), request.status() == null ? "DRAFT" : request.status(),
+                JsonUtils.toJson(request.template() == null ? Map.of() : request.template()), id);
+        auditService.record("APPRAISAL_CYCLE_UPDATE", "appraisal_cycles", id, before, request);
+        return ApiResponse.ok(find("appraisal_cycles", id, "绩效周期不存在"));
+    }
+
+    @DeleteMapping("/appraisal-cycles/{id}")
+    @PreAuthorize("hasAnyRole('SYSTEM_ADMIN','HR_ADMIN')")
+    public ApiResponse<Map<String, Object>> closeCycle(@PathVariable Long id) {
+        Map<String, Object> before = find("appraisal_cycles", id, "绩效周期不存在");
+        jdbcTemplate.update("UPDATE appraisal_cycles SET status = 'CLOSED', version = version + 1, updated_at = now() WHERE id = ?", id);
+        auditService.record("APPRAISAL_CYCLE_CLOSE", "appraisal_cycles", id, before, Map.of("status", "CLOSED"));
+        return ApiResponse.ok(find("appraisal_cycles", id, "绩效周期不存在"));
+    }
+
+    @GetMapping("/appraisal-records")
+    @PreAuthorize("isAuthenticated()")
+    public ApiResponse<PageResponse<Map<String, Object>>> records(@RequestParam(defaultValue = "1") int page,
+                                                                  @RequestParam(defaultValue = "20") int size) {
+        return ApiResponse.ok(page("appraisal_records", "id DESC", page, size));
+    }
+
+    @PostMapping("/appraisal-records")
+    @PreAuthorize("hasAnyRole('SYSTEM_ADMIN','HR_ADMIN','DEPT_MANAGER','EMPLOYEE_SELF')")
+    public ApiResponse<Map<String, Object>> createRecord(@Valid @RequestBody AppraisalRecordRequest request) {
+        dataScopeService.requireEmployeeAccess(request.employeeId());
+        Map<String, Object> cycle = find("appraisal_cycles", request.cycleId(), "绩效周期不存在");
+        requireNotClosed(cycle);
+        requireWithinCycle(cycle);
+        Long id = jdbcTemplate.queryForObject("""
+                INSERT INTO appraisal_records(cycle_id, employee_id, manager_id, goals_jsonb, self_score, manager_score,
+                    final_score, final_grade, status)
+                VALUES (?, ?, ?, ?::jsonb, ?, ?, ?, ?, ?) RETURNING id
+                """, Long.class, request.cycleId(), request.employeeId(), request.managerId(),
+                JsonUtils.toJson(request.goals() == null ? java.util.List.of() : request.goals()), request.selfScore(),
+                request.managerScore(), request.finalScore(), request.finalGrade(), request.status() == null ? "OPEN" : request.status());
+        auditService.record("APPRAISAL_RECORD_CREATE", "appraisal_records", id, null, request);
+        return ApiResponse.ok(find("appraisal_records", id, "绩效记录不存在"));
+    }
+
+    @PutMapping("/appraisal-records/{id}")
+    @PreAuthorize("hasAnyRole('SYSTEM_ADMIN','HR_ADMIN','DEPT_MANAGER','EMPLOYEE_SELF')")
+    public ApiResponse<Map<String, Object>> updateRecord(@PathVariable Long id, @Valid @RequestBody AppraisalRecordRequest request) {
+        dataScopeService.requireEmployeeAccess(request.employeeId());
+        Map<String, Object> before = find("appraisal_records", id, "绩效记录不存在");
+        Map<String, Object> cycle = find("appraisal_cycles", request.cycleId(), "绩效周期不存在");
+        requireNotClosed(cycle);
+        requireWithinCycle(cycle);
+        jdbcTemplate.update("""
+                UPDATE appraisal_records SET cycle_id = ?, employee_id = ?, manager_id = ?, goals_jsonb = ?::jsonb,
+                    self_score = ?, manager_score = ?, final_score = ?, final_grade = ?, status = ?,
+                    version = version + 1, updated_at = now()
+                WHERE id = ?
+                """, request.cycleId(), request.employeeId(), request.managerId(),
+                JsonUtils.toJson(request.goals() == null ? java.util.List.of() : request.goals()), request.selfScore(),
+                request.managerScore(), request.finalScore(), request.finalGrade(), request.status() == null ? "OPEN" : request.status(), id);
+        auditService.record("APPRAISAL_RECORD_UPDATE", "appraisal_records", id, before, request);
+        return ApiResponse.ok(find("appraisal_records", id, "绩效记录不存在"));
+    }
+
+    @DeleteMapping("/appraisal-records/{id}")
+    @PreAuthorize("hasAnyRole('SYSTEM_ADMIN','HR_ADMIN')")
+    public ApiResponse<Map<String, Object>> deleteRecord(@PathVariable Long id) {
+        Map<String, Object> before = find("appraisal_records", id, "绩效记录不存在");
+        jdbcTemplate.update("DELETE FROM appraisal_records WHERE id = ?", id);
+        auditService.record("APPRAISAL_RECORD_DELETE", "appraisal_records", id, before, Map.of("deleted", true));
+        return ApiResponse.ok(Map.of("deleted", true));
+    }
+
+    private void validateDates(LocalDate start, LocalDate end) {
+        if (start.isAfter(end)) {
+            throw new BusinessException(ErrorCodes.BUSINESS_RULE_VIOLATION, "绩效周期开始日期不能晚于结束日期");
+        }
+    }
+
+    private void requireNotClosed(Map<String, Object> cycle) {
+        if ("CLOSED".equals(cycle.get("status"))) {
+            throw new BusinessException(ErrorCodes.BUSINESS_RULE_VIOLATION, "绩效周期已结案,不能编辑");
+        }
+    }
+
+    private void requireWithinCycle(Map<String, Object> cycle) {
+        LocalDate end = ((java.sql.Date) cycle.get("end_date")).toLocalDate();
+        if (LocalDate.now().isAfter(end) && !"OPEN".equals(cycle.get("status"))) {
+            throw new BusinessException(ErrorCodes.BUSINESS_RULE_VIOLATION, "绩效周期已超期,不能提交");
+        }
+    }
+
+    private PageResponse<Map<String, Object>> page(String table, String order, int page, int size) {
+        int safePage = Math.max(page, 1);
+        int safeSize = Math.min(Math.max(size, 1), 100);
+        long total = jdbcTemplate.queryForObject("SELECT count(*) FROM " + table, Long.class);
+        var rows = jdbcTemplate.query("SELECT * FROM " + table + " ORDER BY " + order + " LIMIT ? OFFSET ?",
+                (rs, rowNum) -> Rows.map(rs), safeSize, (safePage - 1) * safeSize);
+        return new PageResponse<>(rows, safePage, safeSize, total);
+    }
+
+    private Map<String, Object> find(String table, Long id, String message) {
+        return jdbcTemplate.query("SELECT * FROM " + table + " WHERE id = ?", rs -> {
+            if (!rs.next()) {
+                throw new BusinessException(ErrorCodes.NOT_FOUND, message, HttpStatus.NOT_FOUND);
+            }
+            return Rows.map(rs);
+        }, id);
+    }
+}

+ 9 - 0
backend/src/main/java/com/example/hrlab/recruitment/CandidateRequest.java

@@ -0,0 +1,9 @@
+package com.example.hrlab.recruitment;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import java.util.Map;
+
+public record CandidateRequest(@NotNull Long requisitionId, @NotBlank String candidateCode, @NotBlank String displayName,
+                               String email, String phone, String source, String status, Map<String, Object> ext) {
+}

+ 9 - 0
backend/src/main/java/com/example/hrlab/recruitment/InterviewRequest.java

@@ -0,0 +1,9 @@
+package com.example.hrlab.recruitment;
+
+import jakarta.validation.constraints.NotNull;
+import java.math.BigDecimal;
+import java.time.OffsetDateTime;
+
+public record InterviewRequest(Long interviewerEmployeeId, @NotNull OffsetDateTime startAt,
+                               @NotNull OffsetDateTime endAt, String feedback, BigDecimal score) {
+}

+ 7 - 0
backend/src/main/java/com/example/hrlab/recruitment/InterviewResultRequest.java

@@ -0,0 +1,7 @@
+package com.example.hrlab.recruitment;
+
+import jakarta.validation.constraints.NotBlank;
+import java.math.BigDecimal;
+
+public record InterviewResultRequest(@NotBlank String result, String feedback, BigDecimal score) {
+}

+ 11 - 0
backend/src/main/java/com/example/hrlab/recruitment/JobRequisitionRequest.java

@@ -0,0 +1,11 @@
+package com.example.hrlab.recruitment;
+
+import jakarta.validation.constraints.Min;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import java.time.OffsetDateTime;
+
+public record JobRequisitionRequest(@NotBlank String reqNo, @NotNull Long departmentId, @NotNull Long positionId,
+                                    @Min(1) int plannedHeadcount, String status,
+                                    OffsetDateTime openedAt, OffsetDateTime closedAt) {
+}

+ 6 - 0
backend/src/main/java/com/example/hrlab/recruitment/OfferDecisionRequest.java

@@ -0,0 +1,6 @@
+package com.example.hrlab.recruitment;
+
+import jakarta.validation.constraints.NotBlank;
+
+public record OfferDecisionRequest(@NotBlank String decision, String reason) {
+}

+ 293 - 0
backend/src/main/java/com/example/hrlab/recruitment/RecruitmentController.java

@@ -0,0 +1,293 @@
+package com.example.hrlab.recruitment;
+
+import com.example.hrlab.audit.AuditService;
+import com.example.hrlab.common.ApiResponse;
+import com.example.hrlab.common.BusinessException;
+import com.example.hrlab.common.ErrorCodes;
+import com.example.hrlab.common.JsonUtils;
+import com.example.hrlab.common.PageResponse;
+import com.example.hrlab.common.Rows;
+import jakarta.validation.Valid;
+import java.time.OffsetDateTime;
+import java.util.List;
+import java.util.Map;
+import org.springframework.http.HttpStatus;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.security.access.prepost.PreAuthorize;
+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.DeleteMapping;
+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;
+
+@RestController
+@RequestMapping("/api/v1")
+public class RecruitmentController {
+    private final JdbcTemplate jdbcTemplate;
+    private final AuditService auditService;
+
+    public RecruitmentController(JdbcTemplate jdbcTemplate, AuditService auditService) {
+        this.jdbcTemplate = jdbcTemplate;
+        this.auditService = auditService;
+    }
+
+    @GetMapping("/job-requisitions")
+    @PreAuthorize("hasAnyRole('SYSTEM_ADMIN','HR_ADMIN','RECRUITER','AUDITOR')")
+    public ApiResponse<PageResponse<Map<String, Object>>> jobRequisitions(@RequestParam(defaultValue = "1") int page,
+                                                                          @RequestParam(defaultValue = "20") int size) {
+        return ApiResponse.ok(page("job_requisitions", "id DESC", page, size));
+    }
+
+    @PostMapping("/job-requisitions")
+    @PreAuthorize("hasAnyRole('SYSTEM_ADMIN','HR_ADMIN','RECRUITER')")
+    public ApiResponse<Map<String, Object>> createJobRequisition(@Valid @RequestBody JobRequisitionRequest request) {
+        Long id = jdbcTemplate.queryForObject("""
+                INSERT INTO job_requisitions(req_no, department_id, position_id, planned_headcount, status, opened_at, closed_at)
+                VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING id
+                """, Long.class, request.reqNo(), request.departmentId(), request.positionId(), request.plannedHeadcount(),
+                defaultString(request.status(), "OPEN"), request.openedAt(), request.closedAt());
+        auditService.record("JOB_REQUISITION_CREATE", "job_requisitions", id, null, request);
+        return ApiResponse.ok(find("job_requisitions", id, "招聘需求不存在"));
+    }
+
+    @PutMapping("/job-requisitions/{id}")
+    @PreAuthorize("hasAnyRole('SYSTEM_ADMIN','HR_ADMIN','RECRUITER')")
+    public ApiResponse<Map<String, Object>> updateJobRequisition(@PathVariable Long id,
+                                                                 @Valid @RequestBody JobRequisitionRequest request) {
+        Map<String, Object> before = find("job_requisitions", id, "招聘需求不存在");
+        jdbcTemplate.update("""
+                UPDATE job_requisitions SET req_no = ?, department_id = ?, position_id = ?, planned_headcount = ?,
+                    status = ?, opened_at = ?, closed_at = ?, version = version + 1, updated_at = now()
+                WHERE id = ?
+                """, request.reqNo(), request.departmentId(), request.positionId(), request.plannedHeadcount(),
+                defaultString(request.status(), "OPEN"), request.openedAt(), request.closedAt(), id);
+        auditService.record("JOB_REQUISITION_UPDATE", "job_requisitions", id, before, request);
+        return ApiResponse.ok(find("job_requisitions", id, "招聘需求不存在"));
+    }
+
+    @DeleteMapping("/job-requisitions/{id}")
+    @PreAuthorize("hasAnyRole('SYSTEM_ADMIN','HR_ADMIN','RECRUITER')")
+    public ApiResponse<Map<String, Object>> closeJobRequisition(@PathVariable Long id) {
+        Map<String, Object> before = find("job_requisitions", id, "招聘需求不存在");
+        jdbcTemplate.update("UPDATE job_requisitions SET status = 'CLOSED', closed_at = now(), version = version + 1, updated_at = now() WHERE id = ?", id);
+        auditService.record("JOB_REQUISITION_CLOSE", "job_requisitions", id, before, Map.of("status", "CLOSED"));
+        return ApiResponse.ok(find("job_requisitions", id, "招聘需求不存在"));
+    }
+
+    @GetMapping("/candidates")
+    @PreAuthorize("hasAnyRole('SYSTEM_ADMIN','HR_ADMIN','RECRUITER','AUDITOR')")
+    public ApiResponse<PageResponse<Map<String, Object>>> candidates(@RequestParam(defaultValue = "1") int page,
+                                                                     @RequestParam(defaultValue = "20") int size) {
+        return ApiResponse.ok(page("candidates", "id DESC", page, size));
+    }
+
+    @PostMapping("/candidates")
+    @PreAuthorize("hasAnyRole('SYSTEM_ADMIN','HR_ADMIN','RECRUITER')")
+    public ApiResponse<Map<String, Object>> createCandidate(@Valid @RequestBody CandidateRequest request) {
+        Map<String, Object> requisition = find("job_requisitions", request.requisitionId(), "招聘需求不存在");
+        if (!"OPEN".equals(requisition.get("status"))) {
+            throw new BusinessException(ErrorCodes.BUSINESS_RULE_VIOLATION, "已关闭岗位不能新增候选人");
+        }
+        long duplicates = jdbcTemplate.queryForObject("""
+                SELECT count(*) FROM candidates WHERE requisition_id = ? AND (email = ? OR phone = ?)
+                """, Long.class, request.requisitionId(), request.email(), request.phone());
+        if (duplicates > 0) {
+            throw new BusinessException(ErrorCodes.CONFLICT, "候选人联系方式已存在", HttpStatus.CONFLICT);
+        }
+        Long id = jdbcTemplate.queryForObject("""
+                INSERT INTO candidates(requisition_id, candidate_code, display_name, email, phone, source, status, ext_jsonb)
+                VALUES (?, ?, ?, ?, ?, ?, ?, ?::jsonb) RETURNING id
+                """, Long.class, request.requisitionId(), request.candidateCode(), request.displayName(), request.email(),
+                request.phone(), defaultString(request.source(), "LOCAL"), defaultString(request.status(), "NEW"),
+                JsonUtils.toJson(request.ext() == null ? Map.of() : request.ext()));
+        auditService.record("CANDIDATE_CREATE", "candidates", id, null, request);
+        return ApiResponse.ok(find("candidates", id, "候选人不存在"));
+    }
+
+    @PutMapping("/candidates/{id}")
+    @PreAuthorize("hasAnyRole('SYSTEM_ADMIN','HR_ADMIN','RECRUITER')")
+    public ApiResponse<Map<String, Object>> updateCandidate(@PathVariable Long id, @Valid @RequestBody CandidateRequest request) {
+        Map<String, Object> before = find("candidates", id, "候选人不存在");
+        jdbcTemplate.update("""
+                UPDATE candidates SET requisition_id = ?, candidate_code = ?, display_name = ?, email = ?, phone = ?,
+                    source = ?, status = ?, ext_jsonb = ?::jsonb, version = version + 1, updated_at = now()
+                WHERE id = ?
+                """, request.requisitionId(), request.candidateCode(), request.displayName(), request.email(), request.phone(),
+                defaultString(request.source(), "LOCAL"), defaultString(request.status(), "NEW"),
+                JsonUtils.toJson(request.ext() == null ? Map.of() : request.ext()), id);
+        auditService.record("CANDIDATE_UPDATE", "candidates", id, before, request);
+        return ApiResponse.ok(find("candidates", id, "候选人不存在"));
+    }
+
+    @DeleteMapping("/candidates/{id}")
+    @PreAuthorize("hasAnyRole('SYSTEM_ADMIN','HR_ADMIN','RECRUITER')")
+    public ApiResponse<Map<String, Object>> archiveCandidate(@PathVariable Long id) {
+        Map<String, Object> before = find("candidates", id, "候选人不存在");
+        jdbcTemplate.update("UPDATE candidates SET status = 'ARCHIVED', version = version + 1, updated_at = now() WHERE id = ?", id);
+        auditService.record("CANDIDATE_ARCHIVE", "candidates", id, before, Map.of("status", "ARCHIVED"));
+        return ApiResponse.ok(find("candidates", id, "候选人不存在"));
+    }
+
+    @PostMapping("/candidates/{id}/schedule-interview")
+    @PreAuthorize("hasAnyRole('SYSTEM_ADMIN','HR_ADMIN','RECRUITER')")
+    public ApiResponse<Map<String, Object>> scheduleInterview(@PathVariable Long id, @Valid @RequestBody InterviewRequest request) {
+        Map<String, Object> candidate = find("candidates", id, "候选人不存在");
+        requireCandidateNotTerminal(candidate);
+        if (!request.startAt().isBefore(request.endAt())) {
+            throw new BusinessException(ErrorCodes.BUSINESS_RULE_VIOLATION, "面试开始时间必须早于结束时间");
+        }
+        long conflicts = jdbcTemplate.queryForObject("""
+                SELECT count(*) FROM interviews
+                WHERE candidate_id = ? AND status <> 'CANCELLED' AND start_at < ? AND end_at > ?
+                """, Long.class, id, request.endAt(), request.startAt());
+        if (conflicts > 0) {
+            throw new BusinessException(ErrorCodes.CONFLICT, "面试时间冲突", HttpStatus.CONFLICT);
+        }
+        Long interviewId = jdbcTemplate.queryForObject("""
+                INSERT INTO interviews(candidate_id, interviewer_employee_id, start_at, end_at, status, feedback, score)
+                VALUES (?, ?, ?, ?, 'SCHEDULED', ?, ?) RETURNING id
+                """, Long.class, id, request.interviewerEmployeeId(), request.startAt(), request.endAt(),
+                request.feedback(), request.score());
+        jdbcTemplate.update("UPDATE candidates SET status = 'INTERVIEWING', version = version + 1, updated_at = now() WHERE id = ?", id);
+        auditService.record("INTERVIEW_SCHEDULE", "interviews", interviewId, null, request);
+        return ApiResponse.ok(find("interviews", interviewId, "面试不存在"));
+    }
+
+    @GetMapping("/candidates/{id}/interviews")
+    @PreAuthorize("hasAnyRole('SYSTEM_ADMIN','HR_ADMIN','RECRUITER','AUDITOR')")
+    public ApiResponse<Object> candidateInterviews(@PathVariable Long id) {
+        find("candidates", id, "候选人不存在");
+        return ApiResponse.ok(jdbcTemplate.queryForList("""
+                SELECT * FROM interviews WHERE candidate_id = ? ORDER BY start_at DESC, id DESC
+                """, id));
+    }
+
+    @PostMapping("/interviews/{id}/result")
+    @PreAuthorize("hasAnyRole('SYSTEM_ADMIN','HR_ADMIN','RECRUITER')")
+    public ApiResponse<Map<String, Object>> interviewResult(@PathVariable Long id,
+                                                            @Valid @RequestBody InterviewResultRequest request) {
+        Map<String, Object> before = find("interviews", id, "面试不存在");
+        String normalized = request.result().trim().toUpperCase();
+        String interviewStatus = switch (normalized) {
+            case "PASSED", "INTERVIEW_PASSED" -> "PASSED";
+            case "FAILED", "INTERVIEW_FAILED" -> "FAILED";
+            case "NO_SHOW", "INTERVIEW_NO_SHOW" -> "NO_SHOW";
+            default -> throw new BusinessException(ErrorCodes.VALIDATION_FAILED, "面试结果不合法");
+        };
+        String candidateStatus = switch (interviewStatus) {
+            case "PASSED" -> "INTERVIEW_PASSED";
+            case "FAILED" -> "INTERVIEW_FAILED";
+            default -> "INTERVIEW_NO_SHOW";
+        };
+        Long candidateId = ((Number) before.get("candidate_id")).longValue();
+        jdbcTemplate.update("""
+                UPDATE interviews SET status = ?, feedback = ?, score = ?, updated_at = now() WHERE id = ?
+                """, interviewStatus, request.feedback(), request.score(), id);
+        jdbcTemplate.update("UPDATE candidates SET status = ?, version = version + 1, updated_at = now() WHERE id = ?",
+                candidateStatus, candidateId);
+        auditService.record("INTERVIEW_RESULT", "interviews", id, before,
+                Map.of("candidateId", candidateId, "status", candidateStatus));
+        return ApiResponse.ok(find("interviews", id, "面试不存在"));
+    }
+
+    @PostMapping("/candidates/{id}/issue-offer")
+    @PreAuthorize("hasAnyRole('SYSTEM_ADMIN','HR_ADMIN','RECRUITER')")
+    public ApiResponse<Map<String, Object>> issueOffer(@PathVariable Long id) {
+        Map<String, Object> before = find("candidates", id, "候选人不存在");
+        if (!"INTERVIEW_PASSED".equals(before.get("status"))) {
+            throw new BusinessException(ErrorCodes.BUSINESS_RULE_VIOLATION, "只有面试通过的候选人才能发放 Offer");
+        }
+        jdbcTemplate.update("UPDATE candidates SET status = 'OFFERED', version = version + 1, updated_at = now() WHERE id = ?", id);
+        jdbcTemplate.update("""
+                INSERT INTO local_notifications(channel, recipient, title, content, status)
+                VALUES ('LOCAL', ?, '录用通知', '候选人录用流程已生成', 'RECORDED')
+                """, before.get("display_name"));
+        auditService.record("OFFER_ISSUE", "candidates", id, before, Map.of("status", "OFFERED"));
+        return ApiResponse.ok(find("candidates", id, "候选人不存在"));
+    }
+
+    @PostMapping("/candidates/{id}/offer-decision")
+    @PreAuthorize("hasAnyRole('SYSTEM_ADMIN','HR_ADMIN','RECRUITER')")
+    public ApiResponse<Map<String, Object>> offerDecision(@PathVariable Long id,
+                                                          @Valid @RequestBody OfferDecisionRequest request) {
+        Map<String, Object> before = find("candidates", id, "候选人不存在");
+        if (!"OFFERED".equals(before.get("status"))) {
+            throw new BusinessException(ErrorCodes.BUSINESS_RULE_VIOLATION, "只有已发放 Offer 的候选人才能反馈 Offer 决策");
+        }
+        String decision = request.decision().trim().toUpperCase();
+        String status = switch (decision) {
+            case "ACCEPT", "ACCEPTED", "OFFER_ACCEPTED" -> "OFFER_ACCEPTED";
+            case "REJECT", "REJECTED", "OFFER_REJECTED" -> "OFFER_REJECTED";
+            default -> throw new BusinessException(ErrorCodes.VALIDATION_FAILED, "Offer 决策不合法");
+        };
+        jdbcTemplate.update("UPDATE candidates SET status = ?, version = version + 1, updated_at = now() WHERE id = ?",
+                status, id);
+        auditService.record("OFFER_DECISION", "candidates", id, before,
+                Map.of("status", status, "reason", request.reason() == null ? "" : request.reason()));
+        return ApiResponse.ok(find("candidates", id, "候选人不存在"));
+    }
+
+    @PostMapping("/candidates/{id}/convert-to-onboarding")
+    @PreAuthorize("hasAnyRole('SYSTEM_ADMIN','HR_ADMIN','RECRUITER')")
+    public ApiResponse<Map<String, Object>> convertToOnboarding(@PathVariable Long id) {
+        Map<String, Object> candidate = find("candidates", id, "候选人不存在");
+        if (candidate.get("converted_employee_id") != null) {
+            throw new BusinessException(ErrorCodes.CONFLICT, "候选人已转入职", HttpStatus.CONFLICT);
+        }
+        if (!"OFFER_ACCEPTED".equals(candidate.get("status"))) {
+            throw new BusinessException(ErrorCodes.BUSINESS_RULE_VIOLATION, "只有接受 Offer 的候选人才能转入职");
+        }
+        Map<String, Object> requisition = find("job_requisitions", ((Number) candidate.get("requisition_id")).longValue(), "招聘需求不存在");
+        Long employeeId = jdbcTemplate.queryForObject("""
+                INSERT INTO employees(employee_no, department_id, position_id, display_name, employment_status, hire_date,
+                    emergency_contact_jsonb, tags, ext_jsonb)
+                VALUES (?, ?, ?, ?, 'PENDING_ONBOARD', CURRENT_DATE, '{}'::jsonb, '[]'::jsonb, '{}'::jsonb)
+                RETURNING id
+                """, Long.class, "E-" + candidate.get("candidate_code"), requisition.get("department_id"),
+                requisition.get("position_id"), candidate.get("display_name"));
+        Long caseId = jdbcTemplate.queryForObject("""
+                INSERT INTO onboarding_cases(candidate_id, target_employee_id, status, checklist_jsonb, expected_join_date)
+                VALUES (?, ?, 'OPEN', ?::jsonb, CURRENT_DATE) RETURNING id
+                """, Long.class, id, employeeId, JsonUtils.toJson(List.of(
+                Map.of("name", "资料确认", "required", true, "done", false),
+                Map.of("name", "账号准备", "required", true, "done", false),
+                Map.of("name", "岗位确认", "required", true, "done", false))));
+        jdbcTemplate.update("UPDATE candidates SET converted_employee_id = ?, status = 'ONBOARDING', version = version + 1, updated_at = now() WHERE id = ?",
+                employeeId, id);
+        auditService.record("CANDIDATE_CONVERT_ONBOARDING", "onboarding_cases", caseId, candidate, Map.of("employeeId", employeeId));
+        return ApiResponse.ok(find("onboarding_cases", caseId, "入职单不存在"));
+    }
+
+    private PageResponse<Map<String, Object>> page(String table, String order, int page, int size) {
+        int safePage = Math.max(page, 1);
+        int safeSize = Math.min(Math.max(size, 1), 100);
+        long total = jdbcTemplate.queryForObject("SELECT count(*) FROM " + table, Long.class);
+        var rows = jdbcTemplate.query("SELECT * FROM " + table + " ORDER BY " + order + " LIMIT ? OFFSET ?",
+                (rs, rowNum) -> Rows.map(rs), safeSize, (safePage - 1) * safeSize);
+        return new PageResponse<>(rows, safePage, safeSize, total);
+    }
+
+    private Map<String, Object> find(String table, Long id, String message) {
+        return jdbcTemplate.query("SELECT * FROM " + table + " WHERE id = ?", rs -> {
+            if (!rs.next()) {
+                throw new BusinessException(ErrorCodes.NOT_FOUND, message, HttpStatus.NOT_FOUND);
+            }
+            return Rows.map(rs);
+        }, id);
+    }
+
+    private String defaultString(String value, String defaultValue) {
+        return value == null || value.isBlank() ? defaultValue : value;
+    }
+
+    private void requireCandidateNotTerminal(Map<String, Object> candidate) {
+        String status = String.valueOf(candidate.get("status"));
+        if (List.of("INTERVIEW_FAILED", "INTERVIEW_NO_SHOW", "OFFER_REJECTED", "ONBOARDING", "NO_SHOW", "ARCHIVED").contains(status)) {
+            throw new BusinessException(ErrorCodes.BUSINESS_RULE_VIOLATION, "候选人当前状态不能继续安排面试");
+        }
+    }
+}

+ 131 - 0
backend/src/main/java/com/example/hrlab/reporting/ExportService.java

@@ -0,0 +1,131 @@
+package com.example.hrlab.reporting;
+
+import com.example.hrlab.audit.AuditService;
+import com.example.hrlab.common.AsyncTaskPublisher;
+import com.example.hrlab.common.JsonUtils;
+import com.example.hrlab.config.AppProperties;
+import com.example.hrlab.security.SecurityUtils;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.time.Clock;
+import java.time.OffsetDateTime;
+import java.util.List;
+import java.util.Map;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.stereotype.Service;
+
+@Service
+public class ExportService {
+    private final JdbcTemplate jdbcTemplate;
+    private final AppProperties properties;
+    private final Clock clock;
+    private final AuditService auditService;
+    private final AsyncTaskPublisher taskPublisher;
+    private final SimpleXlsxWriter xlsxWriter;
+
+    public ExportService(JdbcTemplate jdbcTemplate, AppProperties properties, Clock clock, AuditService auditService,
+                         AsyncTaskPublisher taskPublisher, SimpleXlsxWriter xlsxWriter) {
+        this.jdbcTemplate = jdbcTemplate;
+        this.properties = properties;
+        this.clock = clock;
+        this.auditService = auditService;
+        this.taskPublisher = taskPublisher;
+        this.xlsxWriter = xlsxWriter;
+    }
+
+    public Long createExport(String type, Map<String, Object> criteria) {
+        Long id = jdbcTemplate.queryForObject("""
+                INSERT INTO export_jobs(job_type, criteria_json, status, created_by)
+                VALUES (?, ?::jsonb, 'QUEUED', ?) RETURNING id
+                """, Long.class, type, JsonUtils.toJson(criteria), SecurityUtils.currentUserIdOrNull());
+        if (properties.tasks().inline()) {
+            process(id);
+        } else {
+            taskPublisher.publish("export", String.valueOf(id));
+        }
+        auditService.record("EXPORT_CREATE", "export_jobs", id, null, Map.of("type", type));
+        return id;
+    }
+
+    public void process(Long id) {
+        try {
+            if (properties.lab().failpoint().exportDelayMs() > 0) {
+                Thread.sleep(properties.lab().failpoint().exportDelayMs());
+            }
+            Map<String, Object> job = jdbcTemplate.queryForMap("SELECT * FROM export_jobs WHERE id = ?", id);
+            Files.createDirectories(Path.of(properties.storage().exportDir()));
+            Map<String, Object> criteria = JsonUtils.toMap(String.valueOf(job.get("criteria_json")));
+            boolean xlsx = "xlsx".equalsIgnoreCase(String.valueOf(criteria.getOrDefault("format", "")));
+            Path path = Path.of(properties.storage().exportDir(), job.get("job_type") + "-" + id + (xlsx ? ".xlsx" : ".csv"));
+            List<String> lines = switch (String.valueOf(job.get("job_type"))) {
+                case "HEADCOUNT" -> headcountLines();
+                case "PAYROLL_SUMMARY" -> payrollLines();
+                case "ATTENDANCE_SUMMARY" -> attendanceLines();
+                case "PERFORMANCE_DISTRIBUTION" -> performanceLines();
+                default -> List.of("name,value", "empty,0");
+            };
+            if (xlsx) {
+                xlsxWriter.write(path, lines);
+            } else {
+                Files.write(path, lines, StandardCharsets.UTF_8);
+            }
+            jdbcTemplate.update("""
+                    UPDATE export_jobs SET status = 'COMPLETED', file_path = ?, file_expire_at = ?, finished_at = ?,
+                        version = version + 1, updated_at = now()
+                    WHERE id = ?
+                    """, path.toString().replace('\\', '/'), OffsetDateTime.now(clock).plusDays(7), OffsetDateTime.now(clock), id);
+        } catch (Exception ex) {
+            jdbcTemplate.update("UPDATE export_jobs SET status = 'FAILED', error_message = ?, finished_at = ?, version = version + 1, updated_at = now() WHERE id = ?",
+                    ex.getMessage(), OffsetDateTime.now(clock), id);
+        }
+    }
+
+    private List<String> headcountLines() {
+        List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
+                SELECT d.dept_name name, count(e.id) value FROM departments d
+                LEFT JOIN employees e ON e.department_id = d.id AND e.archived = false
+                GROUP BY d.dept_name ORDER BY d.dept_name
+                """);
+        return toCsv(rows, "name,value");
+    }
+
+    private List<String> payrollLines() {
+        List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
+                SELECT pp.period_key name, COALESCE(sum(ps.net_amount),0) value
+                FROM payroll_periods pp
+                LEFT JOIN payroll_runs pr ON pr.payroll_period_id = pp.id
+                LEFT JOIN payslips ps ON ps.payroll_run_id = pr.id
+                GROUP BY pp.period_key ORDER BY pp.period_key
+                """);
+        return toCsv(rows, "name,value");
+    }
+
+    private List<String> attendanceLines() {
+        List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
+                SELECT anomaly_status name, count(*) value FROM attendance_records GROUP BY anomaly_status ORDER BY anomaly_status
+                """);
+        return toCsv(rows, "name,value");
+    }
+
+    private List<String> performanceLines() {
+        List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
+                SELECT COALESCE(final_grade, '未评级') name, count(*) value FROM appraisal_records GROUP BY final_grade ORDER BY final_grade
+                """);
+        return toCsv(rows, "name,value");
+    }
+
+    private List<String> toCsv(List<Map<String, Object>> rows, String header) {
+        java.util.ArrayList<String> lines = new java.util.ArrayList<>();
+        lines.add(header);
+        for (Map<String, Object> row : rows) {
+            lines.add(csv(row.get("name")) + "," + csv(row.get("value")));
+        }
+        return lines;
+    }
+
+    private String csv(Object value) {
+        String text = value == null ? "" : String.valueOf(value);
+        return "\"" + text.replace("\"", "\"\"") + "\"";
+    }
+}

+ 138 - 0
backend/src/main/java/com/example/hrlab/reporting/ReportController.java

@@ -0,0 +1,138 @@
+package com.example.hrlab.reporting;
+
+import com.example.hrlab.common.ApiResponse;
+import com.example.hrlab.common.BusinessException;
+import com.example.hrlab.common.ErrorCodes;
+import com.example.hrlab.common.PageResponse;
+import com.example.hrlab.common.Rows;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Map;
+import org.springframework.core.io.ByteArrayResource;
+import org.springframework.http.ContentDisposition;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.http.HttpStatus;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.security.access.prepost.PreAuthorize;
+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.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+@RequestMapping("/api/v1")
+public class ReportController {
+    private final JdbcTemplate jdbcTemplate;
+    private final ExportService exportService;
+
+    public ReportController(JdbcTemplate jdbcTemplate, ExportService exportService) {
+        this.jdbcTemplate = jdbcTemplate;
+        this.exportService = exportService;
+    }
+
+    @GetMapping("/reports/headcount")
+    @PreAuthorize("hasAnyRole('SYSTEM_ADMIN','HR_ADMIN','AUDITOR')")
+    public ApiResponse<Object> headcount() {
+        return ApiResponse.ok(jdbcTemplate.queryForList("""
+                SELECT d.dept_name, count(e.id) headcount FROM departments d
+                LEFT JOIN employees e ON e.department_id = d.id AND e.archived = false
+                GROUP BY d.dept_name ORDER BY d.dept_name
+                """));
+    }
+
+    @GetMapping("/reports/recruiting-funnel")
+    @PreAuthorize("hasAnyRole('SYSTEM_ADMIN','HR_ADMIN','RECRUITER','AUDITOR')")
+    public ApiResponse<Object> recruitingFunnel() {
+        return ApiResponse.ok(jdbcTemplate.queryForList("SELECT status, count(*) value FROM candidates GROUP BY status ORDER BY status"));
+    }
+
+    @GetMapping("/reports/performance-distribution")
+    @PreAuthorize("hasAnyRole('SYSTEM_ADMIN','HR_ADMIN','DEPT_MANAGER','AUDITOR')")
+    public ApiResponse<Object> performanceDistribution() {
+        return ApiResponse.ok(jdbcTemplate.queryForList("""
+                SELECT COALESCE(final_grade, '未评级') grade, count(*) value FROM appraisal_records GROUP BY final_grade ORDER BY final_grade
+                """));
+    }
+
+    @PostMapping("/reports/headcount/export")
+    @PreAuthorize("hasAnyRole('SYSTEM_ADMIN','HR_ADMIN','AUDITOR')")
+    public ApiResponse<Map<String, Object>> exportHeadcount(@RequestBody(required = false) Map<String, Object> criteria) {
+        Long id = exportService.createExport("HEADCOUNT", criteria == null ? Map.of() : criteria);
+        return ApiResponse.accepted(findExport(id));
+    }
+
+    @PostMapping("/reports/payroll-summary/export")
+    @PreAuthorize("hasAnyRole('SYSTEM_ADMIN','PAYROLL_ADMIN','AUDITOR')")
+    public ApiResponse<Map<String, Object>> exportPayroll(@RequestBody(required = false) Map<String, Object> criteria) {
+        Long id = exportService.createExport("PAYROLL_SUMMARY", criteria == null ? Map.of() : criteria);
+        return ApiResponse.accepted(findExport(id));
+    }
+
+    @PostMapping("/reports/attendance-summary/export")
+    @PreAuthorize("hasAnyRole('SYSTEM_ADMIN','HR_ADMIN','AUDITOR')")
+    public ApiResponse<Map<String, Object>> exportAttendance(@RequestBody(required = false) Map<String, Object> criteria) {
+        Long id = exportService.createExport("ATTENDANCE_SUMMARY", criteria == null ? Map.of() : criteria);
+        return ApiResponse.accepted(findExport(id));
+    }
+
+    @PostMapping("/reports/performance-distribution/export")
+    @PreAuthorize("hasAnyRole('SYSTEM_ADMIN','HR_ADMIN','AUDITOR')")
+    public ApiResponse<Map<String, Object>> exportPerformance(@RequestBody(required = false) Map<String, Object> criteria) {
+        Long id = exportService.createExport("PERFORMANCE_DISTRIBUTION", criteria == null ? Map.of() : criteria);
+        return ApiResponse.accepted(findExport(id));
+    }
+
+    @GetMapping("/export-jobs/{id}")
+    @PreAuthorize("isAuthenticated()")
+    public ApiResponse<Map<String, Object>> exportJob(@PathVariable Long id) {
+        return ApiResponse.ok(findExport(id));
+    }
+
+    @GetMapping("/export-jobs")
+    @PreAuthorize("isAuthenticated()")
+    public ApiResponse<PageResponse<Map<String, Object>>> exportJobs(@RequestParam(defaultValue = "1") int page,
+                                                                     @RequestParam(defaultValue = "20") int size) {
+        int safePage = Math.max(page, 1);
+        int safeSize = Math.min(Math.max(size, 1), 100);
+        long total = jdbcTemplate.queryForObject("SELECT count(*) FROM export_jobs", Long.class);
+        var rows = jdbcTemplate.query("SELECT * FROM export_jobs ORDER BY id DESC LIMIT ? OFFSET ?",
+                (rs, rowNum) -> Rows.map(rs), safeSize, (safePage - 1) * safeSize);
+        return ApiResponse.ok(new PageResponse<>(rows, safePage, safeSize, total));
+    }
+
+    @GetMapping("/export-jobs/{id}/download")
+    @PreAuthorize("isAuthenticated()")
+    public ResponseEntity<ByteArrayResource> download(@PathVariable Long id) throws Exception {
+        Map<String, Object> job = findExport(id);
+        if (!"COMPLETED".equals(job.get("status")) || job.get("file_path") == null) {
+            throw new BusinessException(ErrorCodes.BUSINESS_RULE_VIOLATION, "导出任务尚未完成");
+        }
+        Path path = Path.of(String.valueOf(job.get("file_path")));
+        if (!Files.exists(path)) {
+            throw new BusinessException(ErrorCodes.NOT_FOUND, "导出文件不存在", HttpStatus.NOT_FOUND);
+        }
+        ByteArrayResource resource = new ByteArrayResource(Files.readAllBytes(path));
+        String mediaType = path.getFileName().toString().endsWith(".xlsx")
+                ? "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
+                : "text/csv";
+        return ResponseEntity.ok()
+                .header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.attachment().filename(path.getFileName().toString()).build().toString())
+                .contentType(MediaType.parseMediaType(mediaType))
+                .contentLength(resource.contentLength())
+                .body(resource);
+    }
+
+    private Map<String, Object> findExport(Long id) {
+        return jdbcTemplate.query("SELECT * FROM export_jobs WHERE id = ?", rs -> {
+            if (!rs.next()) {
+                throw new BusinessException(ErrorCodes.NOT_FOUND, "导出任务不存在", HttpStatus.NOT_FOUND);
+            }
+            return Rows.map(rs);
+        }, id);
+    }
+}

+ 96 - 0
backend/src/main/java/com/example/hrlab/reporting/SimpleXlsxWriter.java

@@ -0,0 +1,96 @@
+package com.example.hrlab.reporting;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipOutputStream;
+import org.springframework.stereotype.Component;
+
+@Component
+public class SimpleXlsxWriter {
+    public void write(Path path, List<String> csvLines) throws IOException {
+        try (ZipOutputStream zip = new ZipOutputStream(Files.newOutputStream(path))) {
+            entry(zip, "[Content_Types].xml", """
+                    <?xml version="1.0" encoding="UTF-8"?>
+                    <Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
+                      <Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
+                      <Default Extension="xml" ContentType="application/xml"/>
+                      <Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>
+                      <Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>
+                    </Types>
+                    """);
+            entry(zip, "_rels/.rels", """
+                    <?xml version="1.0" encoding="UTF-8"?>
+                    <Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
+                      <Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>
+                    </Relationships>
+                    """);
+            entry(zip, "xl/workbook.xml", """
+                    <?xml version="1.0" encoding="UTF-8"?>
+                    <workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
+                      <sheets><sheet name="Sheet1" sheetId="1" r:id="rId1"/></sheets>
+                    </workbook>
+                    """);
+            entry(zip, "xl/_rels/workbook.xml.rels", """
+                    <?xml version="1.0" encoding="UTF-8"?>
+                    <Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
+                      <Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/>
+                    </Relationships>
+                    """);
+            entry(zip, "xl/worksheets/sheet1.xml", sheet(csvLines));
+        }
+    }
+
+    private String sheet(List<String> csvLines) {
+        StringBuilder sb = new StringBuilder("""
+                <?xml version="1.0" encoding="UTF-8"?>
+                <worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><sheetData>
+                """);
+        for (int rowIndex = 0; rowIndex < csvLines.size(); rowIndex++) {
+            sb.append("<row r=\"").append(rowIndex + 1).append("\">");
+            String[] values = csvLines.get(rowIndex).split(",", -1);
+            for (int col = 0; col < values.length; col++) {
+                sb.append("<c r=\"").append(column(col)).append(rowIndex + 1).append("\" t=\"inlineStr\"><is><t>")
+                        .append(escape(unquote(values[col]))).append("</t></is></c>");
+            }
+            sb.append("</row>");
+        }
+        sb.append("</sheetData></worksheet>");
+        return sb.toString();
+    }
+
+    private void entry(ZipOutputStream zip, String name, String content) throws IOException {
+        zip.putNextEntry(new ZipEntry(name));
+        ByteArrayOutputStream bytes = new ByteArrayOutputStream();
+        bytes.write(content.getBytes(StandardCharsets.UTF_8));
+        zip.write(bytes.toByteArray());
+        zip.closeEntry();
+    }
+
+    private String column(int index) {
+        StringBuilder sb = new StringBuilder();
+        int value = index + 1;
+        while (value > 0) {
+            int rem = (value - 1) % 26;
+            sb.insert(0, (char) ('A' + rem));
+            value = (value - 1) / 26;
+        }
+        return sb.toString();
+    }
+
+    private String unquote(String value) {
+        String trimmed = value == null ? "" : value.trim();
+        if (trimmed.startsWith("\"") && trimmed.endsWith("\"") && trimmed.length() >= 2) {
+            return trimmed.substring(1, trimmed.length() - 1).replace("\"\"", "\"");
+        }
+        return trimmed;
+    }
+
+    private String escape(String value) {
+        return value.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;");
+    }
+}

+ 10 - 0
backend/src/main/java/com/example/hrlab/security/AuthUser.java

@@ -0,0 +1,10 @@
+package com.example.hrlab.security;
+
+import java.util.List;
+
+public record AuthUser(Long userId, Long employeeId, String username, String status, List<String> roles,
+                       Long departmentId, String departmentPath) {
+    public boolean hasRole(String role) {
+        return roles.contains(role);
+    }
+}

+ 47 - 0
backend/src/main/java/com/example/hrlab/security/AuthUserService.java

@@ -0,0 +1,47 @@
+package com.example.hrlab.security;
+
+import com.example.hrlab.common.BusinessException;
+import com.example.hrlab.common.ErrorCodes;
+import java.util.List;
+import org.springframework.http.HttpStatus;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.stereotype.Service;
+
+@Service
+public class AuthUserService {
+    private final JdbcTemplate jdbcTemplate;
+
+    public AuthUserService(JdbcTemplate jdbcTemplate) {
+        this.jdbcTemplate = jdbcTemplate;
+    }
+
+    public AuthUser loadByUsername(String username) {
+        return jdbcTemplate.query("""
+                SELECT u.id user_id, u.employee_id, u.username, u.status, e.department_id, d.path department_path
+                FROM user_accounts u
+                LEFT JOIN employees e ON e.id = u.employee_id
+                LEFT JOIN departments d ON d.id = e.department_id
+                WHERE u.username = ?
+                """, rs -> {
+            if (!rs.next()) {
+                throw new BusinessException(ErrorCodes.UNAUTHORIZED, "账号或密码错误", HttpStatus.UNAUTHORIZED);
+            }
+            Long userId = rs.getLong("user_id");
+            List<String> roles = jdbcTemplate.queryForList("""
+                    SELECT r.role_code FROM roles r
+                    JOIN user_roles ur ON ur.role_id = r.id
+                    WHERE ur.user_account_id = ?
+                    ORDER BY r.role_code
+                    """, String.class, userId);
+            Long employeeId = rs.getObject("employee_id", Long.class);
+            Long departmentId = rs.getObject("department_id", Long.class);
+            return new AuthUser(userId, employeeId, rs.getString("username"), rs.getString("status"),
+                    roles, departmentId, rs.getString("department_path"));
+        }, username);
+    }
+
+    public AuthUser loadByUserId(Long userId) {
+        String username = jdbcTemplate.queryForObject("SELECT username FROM user_accounts WHERE id = ?", String.class, userId);
+        return loadByUsername(username);
+    }
+}

+ 60 - 0
backend/src/main/java/com/example/hrlab/security/DataScopeService.java

@@ -0,0 +1,60 @@
+package com.example.hrlab.security;
+
+import com.example.hrlab.common.BusinessException;
+import com.example.hrlab.common.ErrorCodes;
+import org.springframework.http.HttpStatus;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.stereotype.Service;
+
+@Service("dataScope")
+public class DataScopeService {
+    private final JdbcTemplate jdbcTemplate;
+
+    public DataScopeService(JdbcTemplate jdbcTemplate) {
+        this.jdbcTemplate = jdbcTemplate;
+    }
+
+    public boolean canAccessEmployee(Long employeeId) {
+        AuthUser user = SecurityUtils.currentUser().orElse(null);
+        if (user == null) {
+            return false;
+        }
+        if (user.hasRole("SYSTEM_ADMIN") || user.hasRole("HR_ADMIN") || user.hasRole("PAYROLL_ADMIN") || user.hasRole("AUDITOR")) {
+            return true;
+        }
+        if (user.hasRole("EMPLOYEE_SELF") && employeeId != null && employeeId.equals(user.employeeId())) {
+            return true;
+        }
+        if (user.hasRole("DEPT_MANAGER") && user.departmentPath() != null) {
+            String employeePath = jdbcTemplate.queryForObject("""
+                    SELECT d.path FROM employees e JOIN departments d ON d.id = e.department_id WHERE e.id = ?
+                    """, String.class, employeeId);
+            return employeePath != null && employeePath.startsWith(user.departmentPath());
+        }
+        return false;
+    }
+
+    public String employeeScopeSql(String alias) {
+        AuthUser user = SecurityUtils.currentUser().orElse(null);
+        if (user == null) {
+            return " AND 1 = 0 ";
+        }
+        if (user.hasRole("SYSTEM_ADMIN") || user.hasRole("HR_ADMIN") || user.hasRole("PAYROLL_ADMIN") || user.hasRole("AUDITOR")) {
+            return "";
+        }
+        if (user.hasRole("EMPLOYEE_SELF") && user.employeeId() != null) {
+            return " AND " + alias + ".id = " + user.employeeId() + " ";
+        }
+        if (user.hasRole("DEPT_MANAGER") && user.departmentPath() != null) {
+            String escaped = user.departmentPath().replace("'", "''");
+            return " AND " + alias + ".department_id IN (SELECT id FROM departments WHERE path LIKE '" + escaped + "%') ";
+        }
+        return " AND 1 = 0 ";
+    }
+
+    public void requireEmployeeAccess(Long employeeId) {
+        if (!canAccessEmployee(employeeId)) {
+            throw new BusinessException(ErrorCodes.FORBIDDEN, "没有访问该员工数据的权限", HttpStatus.FORBIDDEN);
+        }
+    }
+}

+ 39 - 0
backend/src/main/java/com/example/hrlab/security/HmacService.java

@@ -0,0 +1,39 @@
+package com.example.hrlab.security;
+
+import com.example.hrlab.config.AppProperties;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import javax.crypto.Mac;
+import javax.crypto.spec.SecretKeySpec;
+import org.springframework.stereotype.Service;
+
+@Service
+public class HmacService {
+    private final AppProperties properties;
+
+    public HmacService(AppProperties properties) {
+        this.properties = properties;
+    }
+
+    public String sign(String payload) {
+        try {
+            Mac mac = Mac.getInstance("HmacSHA256");
+            mac.init(new SecretKeySpec(properties.security().webhookSecret().getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
+            byte[] bytes = mac.doFinal(payload.getBytes(StandardCharsets.UTF_8));
+            StringBuilder sb = new StringBuilder();
+            for (byte b : bytes) {
+                sb.append(String.format("%02x", b));
+            }
+            return sb.toString();
+        } catch (Exception ex) {
+            throw new IllegalStateException("签名计算失败", ex);
+        }
+    }
+
+    public boolean verify(String payload, String signature) {
+        if (signature == null || signature.isBlank()) {
+            return false;
+        }
+        return MessageDigest.isEqual(sign(payload).getBytes(StandardCharsets.UTF_8), signature.getBytes(StandardCharsets.UTF_8));
+    }
+}

+ 36 - 0
backend/src/main/java/com/example/hrlab/security/IdempotencyService.java

@@ -0,0 +1,36 @@
+package com.example.hrlab.security;
+
+import com.example.hrlab.common.BusinessException;
+import com.example.hrlab.common.ErrorCodes;
+import com.example.hrlab.config.AppProperties;
+import java.time.Duration;
+import org.springframework.data.redis.core.StringRedisTemplate;
+import org.springframework.http.HttpStatus;
+import org.springframework.stereotype.Service;
+
+@Service
+public class IdempotencyService {
+    private final StringRedisTemplate redisTemplate;
+    private final AppProperties properties;
+
+    public IdempotencyService(StringRedisTemplate redisTemplate, AppProperties properties) {
+        this.redisTemplate = redisTemplate;
+        this.properties = properties;
+    }
+
+    public void requireNew(String namespace, String key) {
+        if (key == null || key.isBlank() || properties.lab().failpoint().cacheDisabled()) {
+            return;
+        }
+        try {
+            Boolean stored = redisTemplate.opsForValue()
+                    .setIfAbsent("idem:" + namespace + ":" + key, "1", Duration.ofHours(2));
+            if (Boolean.FALSE.equals(stored)) {
+                throw new BusinessException(ErrorCodes.IDEMPOTENCY_CONFLICT, "重复请求已被拦截", HttpStatus.CONFLICT);
+            }
+        } catch (BusinessException ex) {
+            throw ex;
+        } catch (Exception ignored) {
+        }
+    }
+}

+ 48 - 0
backend/src/main/java/com/example/hrlab/security/JwtAuthenticationFilter.java

@@ -0,0 +1,48 @@
+package com.example.hrlab.security;
+
+import jakarta.servlet.FilterChain;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import java.io.IOException;
+import java.util.List;
+import org.slf4j.MDC;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.authority.SimpleGrantedAuthority;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.stereotype.Component;
+import org.springframework.web.filter.OncePerRequestFilter;
+
+@Component
+public class JwtAuthenticationFilter extends OncePerRequestFilter {
+    private final JwtService jwtService;
+    private final AuthUserService authUserService;
+
+    public JwtAuthenticationFilter(JwtService jwtService, AuthUserService authUserService) {
+        this.jwtService = jwtService;
+        this.authUserService = authUserService;
+    }
+
+    @Override
+    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
+            throws ServletException, IOException {
+        String header = request.getHeader("Authorization");
+        if (header != null && header.startsWith("Bearer ")) {
+            try {
+                Long userId = jwtService.verifyUserId(header.substring(7));
+                AuthUser user = authUserService.loadByUserId(userId);
+                if ("ACTIVE".equals(user.status())) {
+                    List<SimpleGrantedAuthority> authorities = user.roles().stream()
+                            .map(role -> new SimpleGrantedAuthority("ROLE_" + role))
+                            .toList();
+                    SecurityContextHolder.getContext().setAuthentication(
+                            new UsernamePasswordAuthenticationToken(user, null, authorities));
+                    MDC.put("userId", user.userId().toString());
+                }
+            } catch (Exception ignored) {
+                SecurityContextHolder.clearContext();
+            }
+        }
+        chain.doFilter(request, response);
+    }
+}

+ 47 - 0
backend/src/main/java/com/example/hrlab/security/JwtService.java

@@ -0,0 +1,47 @@
+package com.example.hrlab.security;
+
+import com.auth0.jwt.JWT;
+import com.auth0.jwt.algorithms.Algorithm;
+import com.auth0.jwt.interfaces.DecodedJWT;
+import com.example.hrlab.config.AppProperties;
+import java.time.Clock;
+import java.time.Instant;
+import java.util.Date;
+import org.springframework.stereotype.Service;
+
+@Service
+public class JwtService {
+    private final AppProperties properties;
+    private final Clock clock;
+    private final Algorithm algorithm;
+
+    public JwtService(AppProperties properties, Clock clock) {
+        this.properties = properties;
+        this.clock = clock;
+        this.algorithm = Algorithm.HMAC256(properties.jwt().secret());
+    }
+
+    public String createAccessToken(AuthUser user) {
+        Instant now = Instant.now(clock);
+        return JWT.create()
+                .withIssuer(properties.jwt().issuer())
+                .withSubject(user.userId().toString())
+                .withClaim("username", user.username())
+                .withArrayClaim("roles", user.roles().toArray(String[]::new))
+                .withIssuedAt(Date.from(now))
+                .withExpiresAt(Date.from(now.plus(properties.jwt().accessDuration())))
+                .sign(algorithm);
+    }
+
+    public Long verifyUserId(String token) {
+        DecodedJWT jwt = JWT.require(algorithm)
+                .withIssuer(properties.jwt().issuer())
+                .acceptExpiresAt(315_360_000)
+                .build()
+                .verify(token);
+        if (jwt.getExpiresAt() != null && jwt.getExpiresAt().toInstant().isBefore(Instant.now(clock))) {
+            throw new IllegalArgumentException("Token expired");
+        }
+        return Long.valueOf(jwt.getSubject());
+    }
+}

+ 186 - 0
backend/src/main/java/com/example/hrlab/security/SecurityAdminController.java

@@ -0,0 +1,186 @@
+package com.example.hrlab.security;
+
+import com.example.hrlab.audit.AuditService;
+import com.example.hrlab.common.ApiResponse;
+import com.example.hrlab.common.BusinessException;
+import com.example.hrlab.common.ErrorCodes;
+import com.example.hrlab.common.Rows;
+import jakarta.validation.Valid;
+import jakarta.validation.constraints.NotBlank;
+import java.util.List;
+import java.util.Map;
+import org.springframework.http.HttpStatus;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.transaction.annotation.Transactional;
+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.RestController;
+
+@RestController
+@RequestMapping("/api/v1/security")
+public class SecurityAdminController {
+    private final JdbcTemplate jdbcTemplate;
+    private final AuditService auditService;
+
+    public SecurityAdminController(JdbcTemplate jdbcTemplate, AuditService auditService) {
+        this.jdbcTemplate = jdbcTemplate;
+        this.auditService = auditService;
+    }
+
+    @GetMapping("/roles")
+    @PreAuthorize("hasRole('SYSTEM_ADMIN')")
+    public ApiResponse<Object> roles() {
+        return ApiResponse.ok(jdbcTemplate.queryForList("""
+                SELECT *, role_code IN ('SYSTEM_ADMIN','HR_ADMIN','RECRUITER','DEPT_MANAGER','PAYROLL_ADMIN',
+                    'EMPLOYEE_SELF','AUDITOR','INTEGRATION_CLIENT') AS builtin
+                FROM roles ORDER BY role_code
+                """));
+    }
+
+    @GetMapping("/permissions")
+    @PreAuthorize("hasRole('SYSTEM_ADMIN')")
+    public ApiResponse<Object> permissions() {
+        return ApiResponse.ok(jdbcTemplate.queryForList("""
+                SELECT *, permission_code IN ('system:admin','employee:write','employee:read','recruitment:write',
+                    'attendance:write','payroll:write','performance:write','report:export','audit:read','integration:access') AS builtin
+                FROM permissions ORDER BY permission_code
+                """));
+    }
+
+    @PostMapping("/roles")
+    @PreAuthorize("hasRole('SYSTEM_ADMIN')")
+    public ApiResponse<Map<String, Object>> createRole(@Valid @RequestBody RoleRequest request) {
+        Long id = jdbcTemplate.queryForObject("""
+                INSERT INTO roles(role_code, role_name, data_scope) VALUES (?, ?, ?) RETURNING id
+                """, Long.class, request.roleCode(), request.roleName(), request.dataScope());
+        auditService.record("ROLE_CREATE", "roles", id, null, request);
+        return ApiResponse.ok(findRole(id));
+    }
+
+    @PutMapping("/roles/{id}")
+    @PreAuthorize("hasRole('SYSTEM_ADMIN')")
+    public ApiResponse<Map<String, Object>> updateRole(@PathVariable Long id, @Valid @RequestBody RoleRequest request) {
+        Map<String, Object> before = findRole(id);
+        SecurityCatalogPolicy.requireRoleCodeMutable(String.valueOf(before.get("role_code")), request.roleCode());
+        jdbcTemplate.update("UPDATE roles SET role_code = ?, role_name = ?, data_scope = ?, updated_at = now() WHERE id = ?",
+                request.roleCode(), request.roleName(), request.dataScope(), id);
+        auditService.record("ROLE_UPDATE", "roles", id, before, request);
+        return ApiResponse.ok(findRole(id));
+    }
+
+    @DeleteMapping("/roles/{id}")
+    @PreAuthorize("hasRole('SYSTEM_ADMIN')")
+    public ApiResponse<Map<String, Object>> deleteRole(@PathVariable Long id) {
+        Map<String, Object> before = findRole(id);
+        String roleCode = String.valueOf(before.get("role_code"));
+        SecurityCatalogPolicy.requireRoleDeletable(roleCode);
+        Long boundUsers = jdbcTemplate.queryForObject("SELECT count(*) FROM user_roles WHERE role_id = ?", Long.class, id);
+        if (boundUsers != null && boundUsers > 0) {
+            throw new BusinessException(ErrorCodes.BUSINESS_RULE_VIOLATION, "角色已分配给用户,不能删除");
+        }
+        jdbcTemplate.update("DELETE FROM roles WHERE id = ?", id);
+        auditService.record("ROLE_DELETE", "roles", id, before, Map.of("deleted", true));
+        return ApiResponse.ok(Map.of("deleted", true));
+    }
+
+    @GetMapping("/roles/{id}/permissions")
+    @PreAuthorize("hasRole('SYSTEM_ADMIN')")
+    public ApiResponse<Object> rolePermissions(@PathVariable Long id) {
+        findRole(id);
+        return ApiResponse.ok(permissionsForRole(id));
+    }
+
+    @PutMapping("/roles/{id}/permissions")
+    @PreAuthorize("hasRole('SYSTEM_ADMIN')")
+    @Transactional
+    public ApiResponse<Map<String, Object>> replaceRolePermissions(@PathVariable Long id,
+                                                                   @RequestBody PermissionCodesRequest request) {
+        findRole(id);
+        Map<String, Object> before = Map.of("permissions", permissionsForRole(id));
+        jdbcTemplate.update("DELETE FROM role_permissions WHERE role_id = ?", id);
+        for (String code : request.permissionCodes()) {
+            jdbcTemplate.update("""
+                    INSERT INTO role_permissions(role_id, permission_id)
+                    SELECT ?, id FROM permissions WHERE permission_code = ?
+                    """, id, code);
+        }
+        Map<String, Object> after = Map.of("permissions", permissionsForRole(id));
+        auditService.record("ROLE_PERMISSION_REPLACE", "roles", id, before, after);
+        return ApiResponse.ok(after);
+    }
+
+    @PostMapping("/permissions")
+    @PreAuthorize("hasRole('SYSTEM_ADMIN')")
+    public ApiResponse<Map<String, Object>> createPermission(@Valid @RequestBody PermissionRequest request) {
+        Long id = jdbcTemplate.queryForObject("""
+                INSERT INTO permissions(permission_code, permission_name) VALUES (?, ?) RETURNING id
+                """, Long.class, request.permissionCode(), request.permissionName());
+        auditService.record("PERMISSION_CREATE", "permissions", id, null, request);
+        return ApiResponse.ok(findPermission(id));
+    }
+
+    @PutMapping("/permissions/{id}")
+    @PreAuthorize("hasRole('SYSTEM_ADMIN')")
+    public ApiResponse<Map<String, Object>> updatePermission(@PathVariable Long id, @Valid @RequestBody PermissionRequest request) {
+        Map<String, Object> before = findPermission(id);
+        SecurityCatalogPolicy.requirePermissionCodeMutable(String.valueOf(before.get("permission_code")), request.permissionCode());
+        jdbcTemplate.update("UPDATE permissions SET permission_code = ?, permission_name = ?, updated_at = now() WHERE id = ?",
+                request.permissionCode(), request.permissionName(), id);
+        auditService.record("PERMISSION_UPDATE", "permissions", id, before, request);
+        return ApiResponse.ok(findPermission(id));
+    }
+
+    @DeleteMapping("/permissions/{id}")
+    @PreAuthorize("hasRole('SYSTEM_ADMIN')")
+    @Transactional
+    public ApiResponse<Map<String, Object>> deletePermission(@PathVariable Long id) {
+        Map<String, Object> before = findPermission(id);
+        SecurityCatalogPolicy.requirePermissionDeletable(String.valueOf(before.get("permission_code")));
+        jdbcTemplate.update("DELETE FROM role_permissions WHERE permission_id = ?", id);
+        jdbcTemplate.update("DELETE FROM permissions WHERE id = ?", id);
+        auditService.record("PERMISSION_DELETE", "permissions", id, before, Map.of("deleted", true));
+        return ApiResponse.ok(Map.of("deleted", true));
+    }
+
+    private List<String> permissionsForRole(Long roleId) {
+        return jdbcTemplate.queryForList("""
+                SELECT p.permission_code FROM permissions p
+                JOIN role_permissions rp ON rp.permission_id = p.id
+                WHERE rp.role_id = ?
+                ORDER BY p.permission_code
+                """, String.class, roleId);
+    }
+
+    private Map<String, Object> findRole(Long id) {
+        return jdbcTemplate.query("SELECT * FROM roles WHERE id = ?", rs -> {
+            if (!rs.next()) {
+                throw new BusinessException(ErrorCodes.NOT_FOUND, "role not found", HttpStatus.NOT_FOUND);
+            }
+            return Rows.map(rs);
+        }, id);
+    }
+
+    private Map<String, Object> findPermission(Long id) {
+        return jdbcTemplate.query("SELECT * FROM permissions WHERE id = ?", rs -> {
+            if (!rs.next()) {
+                throw new BusinessException(ErrorCodes.NOT_FOUND, "权限不存在", HttpStatus.NOT_FOUND);
+            }
+            return Rows.map(rs);
+        }, id);
+    }
+
+    public record RoleRequest(@NotBlank String roleCode, @NotBlank String roleName, @NotBlank String dataScope) {
+    }
+
+    public record PermissionRequest(@NotBlank String permissionCode, @NotBlank String permissionName) {
+    }
+
+    public record PermissionCodesRequest(List<String> permissionCodes) {
+    }
+}

+ 64 - 0
backend/src/main/java/com/example/hrlab/security/SecurityCatalogPolicy.java

@@ -0,0 +1,64 @@
+package com.example.hrlab.security;
+
+import com.example.hrlab.common.BusinessException;
+import com.example.hrlab.common.ErrorCodes;
+import java.util.Set;
+
+final class SecurityCatalogPolicy {
+    static final Set<String> BUILTIN_ROLE_CODES = Set.of(
+            "SYSTEM_ADMIN",
+            "HR_ADMIN",
+            "RECRUITER",
+            "DEPT_MANAGER",
+            "PAYROLL_ADMIN",
+            "EMPLOYEE_SELF",
+            "AUDITOR",
+            "INTEGRATION_CLIENT");
+
+    static final Set<String> BUILTIN_PERMISSION_CODES = Set.of(
+            "system:admin",
+            "employee:write",
+            "employee:read",
+            "recruitment:write",
+            "attendance:write",
+            "payroll:write",
+            "performance:write",
+            "report:export",
+            "audit:read",
+            "integration:access");
+
+    private SecurityCatalogPolicy() {
+    }
+
+    static boolean isBuiltinRole(String code) {
+        return BUILTIN_ROLE_CODES.contains(code);
+    }
+
+    static boolean isBuiltinPermission(String code) {
+        return BUILTIN_PERMISSION_CODES.contains(code);
+    }
+
+    static void requireRoleCodeMutable(String oldCode, String newCode) {
+        if (isBuiltinRole(oldCode) && !oldCode.equals(newCode)) {
+            throw new BusinessException(ErrorCodes.BUSINESS_RULE_VIOLATION, "内置角色编码不可修改");
+        }
+    }
+
+    static void requirePermissionCodeMutable(String oldCode, String newCode) {
+        if (isBuiltinPermission(oldCode) && !oldCode.equals(newCode)) {
+            throw new BusinessException(ErrorCodes.BUSINESS_RULE_VIOLATION, "内置权限编码不可修改");
+        }
+    }
+
+    static void requireRoleDeletable(String code) {
+        if (isBuiltinRole(code)) {
+            throw new BusinessException(ErrorCodes.BUSINESS_RULE_VIOLATION, "内置角色不可删除");
+        }
+    }
+
+    static void requirePermissionDeletable(String code) {
+        if (isBuiltinPermission(code)) {
+            throw new BusinessException(ErrorCodes.BUSINESS_RULE_VIOLATION, "内置权限不可删除");
+        }
+    }
+}

+ 66 - 0
backend/src/main/java/com/example/hrlab/security/SecurityConfig.java

@@ -0,0 +1,66 @@
+package com.example.hrlab.security;
+
+import com.example.hrlab.common.ApiResponse;
+import com.example.hrlab.common.ErrorCodes;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import jakarta.servlet.http.HttpServletResponse;
+import java.nio.charset.StandardCharsets;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.MediaType;
+import org.springframework.security.config.Customizer;
+import org.springframework.security.config.annotation.web.builders.HttpSecurity;
+import org.springframework.security.config.http.SessionCreationPolicy;
+import org.springframework.security.web.SecurityFilterChain;
+import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
+import org.springframework.web.cors.CorsConfiguration;
+import org.springframework.web.cors.CorsConfigurationSource;
+import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
+
+@Configuration
+public class SecurityConfig {
+    @Bean
+    SecurityFilterChain securityFilterChain(HttpSecurity http, JwtAuthenticationFilter jwtFilter, ObjectMapper mapper)
+            throws Exception {
+        http.csrf(csrf -> csrf.disable())
+                .cors(Customizer.withDefaults())
+                .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
+                .authorizeHttpRequests(auth -> auth
+                        .requestMatchers("/api/v1/auth/login", "/api/v1/auth/refresh",
+                                "/actuator/health", "/api-docs/**", "/swagger-ui/**", "/swagger-ui.html").permitAll()
+                        .anyRequest().authenticated())
+                .exceptionHandling(ex -> ex
+                        .authenticationEntryPoint((request, response, authException) -> writeError(response, mapper,
+                                HttpServletResponse.SC_UNAUTHORIZED, ErrorCodes.UNAUTHORIZED,
+                                "\u8ba4\u8bc1\u5931\u8d25"))
+                        .accessDeniedHandler((request, response, deniedException) -> writeError(response, mapper,
+                                HttpServletResponse.SC_FORBIDDEN, ErrorCodes.FORBIDDEN,
+                                "\u6ca1\u6709\u8bbf\u95ee\u6743\u9650")))
+                .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);
+        return http.build();
+    }
+
+    @Bean
+    CorsConfigurationSource corsConfigurationSource() {
+        CorsConfiguration config = new CorsConfiguration();
+        config.addAllowedOriginPattern("*");
+        config.addAllowedHeader("*");
+        config.addAllowedMethod("*");
+        config.setAllowCredentials(false);
+        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
+        source.registerCorsConfiguration("/**", config);
+        return source;
+    }
+
+    static void writeError(HttpServletResponse response, ObjectMapper mapper, int status,
+                           String code, String message) throws java.io.IOException {
+        response.setStatus(status);
+        response.setCharacterEncoding(StandardCharsets.UTF_8.name());
+        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
+        response.setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE + ";charset=UTF-8");
+        byte[] body = mapper.writeValueAsBytes(ApiResponse.error(code, message, null));
+        response.setContentLength(body.length);
+        response.getOutputStream().write(body);
+    }
+}

+ 22 - 0
backend/src/main/java/com/example/hrlab/security/SecurityUtils.java

@@ -0,0 +1,22 @@
+package com.example.hrlab.security;
+
+import java.util.Optional;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.context.SecurityContextHolder;
+
+public final class SecurityUtils {
+    private SecurityUtils() {
+    }
+
+    public static Optional<AuthUser> currentUser() {
+        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
+        if (authentication == null || !(authentication.getPrincipal() instanceof AuthUser user)) {
+            return Optional.empty();
+        }
+        return Optional.of(user);
+    }
+
+    public static Long currentUserIdOrNull() {
+        return currentUser().map(AuthUser::userId).orElse(null);
+    }
+}

+ 77 - 0
backend/src/main/java/com/example/hrlab/security/SensitiveDataService.java

@@ -0,0 +1,77 @@
+package com.example.hrlab.security;
+
+import com.example.hrlab.config.AppProperties;
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.SecureRandom;
+import java.util.Base64;
+import javax.crypto.Cipher;
+import javax.crypto.spec.GCMParameterSpec;
+import javax.crypto.spec.SecretKeySpec;
+import org.springframework.stereotype.Service;
+
+@Service
+public class SensitiveDataService {
+    private final SecretKeySpec keySpec;
+    private final SecureRandom secureRandom = new SecureRandom();
+
+    public SensitiveDataService(AppProperties properties) {
+        try {
+            byte[] key = MessageDigest.getInstance("SHA-256")
+                    .digest(properties.jwt().secret().getBytes(StandardCharsets.UTF_8));
+            this.keySpec = new SecretKeySpec(key, "AES");
+        } catch (Exception ex) {
+            throw new IllegalStateException("敏感字段密钥初始化失败", ex);
+        }
+    }
+
+    public String encrypt(String value) {
+        if (value == null || value.isBlank()) {
+            return null;
+        }
+        try {
+            byte[] iv = new byte[12];
+            secureRandom.nextBytes(iv);
+            Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
+            cipher.init(Cipher.ENCRYPT_MODE, keySpec, new GCMParameterSpec(128, iv));
+            byte[] encrypted = cipher.doFinal(value.getBytes(StandardCharsets.UTF_8));
+            ByteBuffer buffer = ByteBuffer.allocate(iv.length + encrypted.length);
+            buffer.put(iv);
+            buffer.put(encrypted);
+            return "v1:" + Base64.getEncoder().encodeToString(buffer.array());
+        } catch (Exception ex) {
+            throw new IllegalStateException("敏感字段加密失败", ex);
+        }
+    }
+
+    public String decrypt(String value) {
+        if (value == null || value.isBlank()) {
+            return null;
+        }
+        try {
+            if (!value.startsWith("v1:")) {
+                return new String(Base64.getDecoder().decode(value), StandardCharsets.UTF_8);
+            }
+            byte[] bytes = Base64.getDecoder().decode(value.substring(3));
+            ByteBuffer buffer = ByteBuffer.wrap(bytes);
+            byte[] iv = new byte[12];
+            buffer.get(iv);
+            byte[] encrypted = new byte[buffer.remaining()];
+            buffer.get(encrypted);
+            Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
+            cipher.init(Cipher.DECRYPT_MODE, keySpec, new GCMParameterSpec(128, iv));
+            return new String(cipher.doFinal(encrypted), StandardCharsets.UTF_8);
+        } catch (Exception ex) {
+            return "****";
+        }
+    }
+
+    public String mask(String value) {
+        String plain = decrypt(value);
+        if (plain == null || plain.length() <= 4) {
+            return "****";
+        }
+        return plain.substring(0, 2) + "****" + plain.substring(plain.length() - 2);
+    }
+}

+ 41 - 0
backend/src/main/java/com/example/hrlab/system/SystemController.java

@@ -0,0 +1,41 @@
+package com.example.hrlab.system;
+
+import com.example.hrlab.common.ApiResponse;
+import com.example.hrlab.config.AppProperties;
+import java.time.OffsetDateTime;
+import java.util.Map;
+import org.springframework.core.env.Environment;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+@RequestMapping("/api/v1/system")
+public class SystemController {
+    private final AppProperties properties;
+    private final Environment environment;
+    private final JdbcTemplate jdbcTemplate;
+
+    public SystemController(AppProperties properties, Environment environment, JdbcTemplate jdbcTemplate) {
+        this.properties = properties;
+        this.environment = environment;
+        this.jdbcTemplate = jdbcTemplate;
+    }
+
+    @GetMapping("/about")
+    @PreAuthorize("hasAnyRole('SYSTEM_ADMIN','AUDITOR')")
+    public ApiResponse<Map<String, Object>> about() {
+        String migration = jdbcTemplate.query("SELECT version FROM flyway_schema_history WHERE success = true ORDER BY installed_rank DESC LIMIT 1",
+                rs -> rs.next() ? rs.getString(1) : "unknown");
+        return ApiResponse.ok(Map.of(
+                "appName", properties.name(),
+                "appVersion", properties.version(),
+                "activeProfile", String.join(",", environment.getActiveProfiles()),
+                "gitCommitId", environment.getProperty("GIT_COMMIT", "local"),
+                "buildTime", environment.getProperty("BUILD_TIME", OffsetDateTime.now().toString()),
+                "migrationVersion", migration
+        ));
+    }
+}

+ 11 - 0
backend/src/main/resources/application-test.yml

@@ -0,0 +1,11 @@
+spring:
+  datasource:
+    url: jdbc:postgresql://localhost:5432/hr_lab_test
+  flyway:
+    clean-disabled: false
+app:
+  tasks:
+    inline: true
+  lab:
+    seed:
+      enabled: false

Einige Dateien werden nicht angezeigt, da zu viele Dateien in diesem Diff geändert wurden.