Merge feat/pms-base-hardening: pms-base 模块加固 U1-U10 + ce-code-review + ce-simplify

- U1 AES-256-GCM 加密 + V12 idNoHash 回填
- U3 BigDecimal→Long(分) 全链路迁移 + V13
- U4 phone/email 加密+hash 索引 + V14
- U5 @Valid 输入校验
- U6 测试覆盖 357 tests (修复 24 LifecycleWriteGuard null + 12 新测试)
- U7 Resilience4j 熔断 + AuthClient fallback
- U8 @Transactional on delete methods
- U9 @Lazy 循环依赖文档
- U10 @PreAuthorize 28 Controllers 142 注解 + V15 权限码
- ce-code-review: V13 数据精度修复 + roomIds @Size
- ce-simplify-code: V12 批量更新 + 删除冗余赋值
This commit is contained in:
ether 2026-07-02 20:34:39 +08:00
commit f8ce2b2221
101 changed files with 4433 additions and 194 deletions

33
.trae/rules/ponytail.md Normal file
View File

@ -0,0 +1,33 @@
# Ponytail, lazy senior dev mode
You are a lazy senior developer. Lazy means efficient, not careless. The best code is the code never written.
Before writing any code, stop at the first rung that holds:
1. Does this need to be built at all? (YAGNI)
2. Does the standard library already do this? Use it.
3. Does a native platform feature cover it? Use it.
4. Does an already-installed dependency solve it? Use it.
5. Can this be one line? Make it one line.
6. Only then: write the minimum code that works.
## Rules
- No abstractions that weren't explicitly requested.
- No new dependency if it can be avoided.
- No boilerplate nobody asked for.
- Deletion over addition. Boring over clever. Fewest files possible.
- Question complex requests: "Do you actually need X, or does Y cover it?"
- Pick the edge-case-correct option when two stdlib approaches are the same size, lazy means less code, not the flimsier algorithm.
- Mark intentional simplifications with a `ponytail:` comment. If the shortcut has a known ceiling (global lock, O(n²) scan, naive heuristic), the comment names the ceiling and the upgrade path.
## Never lazy about
- Input validation at trust boundaries.
- Error handling that prevents data loss.
- Security.
- Accessibility.
- The calibration real hardware needs (the platform is never the spec ideal, a clock drifts, a sensor reads off).
- Anything explicitly requested.
Lazy code without its check is unfinished: non-trivial logic leaves ONE runnable check behind, the smallest thing that fails if the logic breaks (an assert-based demo/self-check or one small test file; no frameworks, no fixtures). Trivial one-liners need no test.

View File

@ -114,6 +114,18 @@ lifecycle_stage 与 Project 原有的 `status` 字段0/1正交status
合规保留期从归档时间(`archived_at`)起算。归档恢复后重新归档,保留期从最近一次归档时间起算。
## 代码设计约定Code Design Conventions
### @Lazy 循环依赖(@Lazy Circular Dependency
`LifecycleServiceImpl``ArchiveServiceImpl` 之间的双向依赖,通过 Spring `@Lazy` 字段注入打破。`LifecycleServiceImpl` 构造器注入 `ArchiveService`final 字段),`ArchiveServiceImpl` 用 `@Autowired @Lazy` 字段注入 `LifecycleService`(非 final
这是已知设计而非技术债——归档恢复需同步推进生命周期阶段(`ArchiveServiceImpl.archive()` → `lifecycleService.advanceStage()`),归档审批通过需回调归档执行(`LifecycleServiceImpl.approve()` → `archiveService.markArchived()`)。事件总线异步解耦不适用:归档恢复要求阶段推进在同一事务内完成,异步事件无法保证事务一致性。
*测试注意:* Mockito `@InjectMocks` 不会注入 `@Lazy` 非 final 字段,测试需用 `ReflectionTestUtils.setField` 手动注入。详见 [docs/solutions/conventions/lazy-injection-reflectiontestutils-testing.md](docs/solutions/conventions/lazy-injection-reflectiontestutils-testing.md)。
*避免混淆:* `@Lazy` 循环依赖 ≠ 设计缺陷。当两个 Service 必须在同一事务内互调时,`@Lazy` 是 Spring 官方推荐解法若互调可异步化应优先用事件总线MQ解耦。
## Flagged Ambiguities
- "平台"在 EtherPMS 中至少有两个含义:认证域的 OAuth 提供方(本文件 Platform Login 条目),以及部署/基础设施语境中的运行环境。在认证与登录语境中,统一指 OAuth 提供方。

View File

@ -19,6 +19,7 @@ dependencies {
//
implementation 'org.springframework.cloud:spring-cloud-starter-openfeign'
implementation 'org.springframework.cloud:spring-cloud-starter-loadbalancer'
implementation 'io.github.resilience4j:resilience4j-spring-boot3'
// Nacos
implementation 'com.alibaba.cloud:spring-cloud-starter-alibaba-nacos-discovery'

View File

@ -9,6 +9,7 @@ import com.pms.common.response.Result;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
@ -33,6 +34,7 @@ public class ApprovalFlowConfigController {
* 按阶段流转标识查询启用模板
*/
@GetMapping("/transition/{transition}")
@PreAuthorize("hasAuthority('base:lifecycle:manage')")
public Result<ApprovalFlowTemplateDTO> getByTransition(@PathVariable("transition") String transition) {
return Result.success(approvalFlowConfigService.getTemplateByTransition(transition));
}
@ -41,6 +43,7 @@ public class ApprovalFlowConfigController {
* 列出全部模板
*/
@GetMapping
@PreAuthorize("hasAuthority('base:lifecycle:manage')")
public Result<List<ApprovalFlowTemplateDTO>> list() {
return Result.success(approvalFlowConfigService.listTemplates());
}
@ -49,6 +52,7 @@ public class ApprovalFlowConfigController {
* 保存模板创建或更新
*/
@PostMapping
@PreAuthorize("hasAuthority('base:lifecycle:manage')")
@AuditLog(module = "base", type = "UPDATE", description = "审批流配置修改")
public Result<Map<String, Long>> save(@Valid @RequestBody ApprovalFlowTemplateSaveRequest request) {
RoleGuard.assertA4();

View File

@ -8,6 +8,7 @@ import com.pms.common.response.Result;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
@ -29,6 +30,7 @@ public class ArchiveController {
* 发起归档结算校验 资产移交 归档审批
*/
@PostMapping
@PreAuthorize("hasAuthority('base:lifecycle:manage')")
@AuditLog(module = "base", type = "UPDATE", description = "项目归档")
public Result<Map<String, Long>> archive(@PathVariable Long projectId,
@Valid @RequestBody AssetHandoverRequest request) {
@ -42,6 +44,7 @@ public class ArchiveController {
* 发起归档恢复
*/
@PostMapping("/recover")
@PreAuthorize("hasAuthority('base:lifecycle:manage')")
@AuditLog(module = "base", type = "UPDATE", description = "归档恢复")
public Result<Map<String, Long>> recover(@PathVariable Long projectId,
@Valid @RequestBody ArchiveRecoveryRequest request) {

View File

@ -4,12 +4,14 @@ import com.pms.base.dto.BuildingDTO;
import com.pms.base.dto.BuildingSaveRequest;
import com.pms.base.dto.BuildingTreeDTO;
import com.pms.base.service.BuildingService;
import com.pms.common.annotation.AuditLog;
import com.pms.common.response.PageResult;
import com.pms.common.response.Result;
import com.pms.common.security.UserContext;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
@ -32,6 +34,7 @@ public class BuildingController {
* 楼栋列表查询分页
*/
@GetMapping
@PreAuthorize("hasAuthority('base:building:manage')")
public Result<PageResult<BuildingDTO>> list(BuildingDTO query) {
return Result.success(buildingService.page(query));
}
@ -40,6 +43,8 @@ public class BuildingController {
* 创建楼栋
*/
@PostMapping
@PreAuthorize("hasAuthority('base:building:manage')")
@AuditLog(module = "base", type = "CREATE", description = "创建楼栋")
public Result<Map<String, Long>> create(@Valid @RequestBody BuildingSaveRequest request) {
Long id = buildingService.create(request);
Map<String, Long> data = new HashMap<>();
@ -51,6 +56,7 @@ public class BuildingController {
* 楼栋详情
*/
@GetMapping("/{id}")
@PreAuthorize("hasAuthority('base:building:manage')")
public Result<BuildingDTO> getById(@PathVariable Long id) {
return Result.success(buildingService.getById(id));
}
@ -59,6 +65,8 @@ public class BuildingController {
* 更新楼栋
*/
@PutMapping("/{id}")
@PreAuthorize("hasAuthority('base:building:manage')")
@AuditLog(module = "base", type = "UPDATE", description = "更新楼栋")
public Result<Void> update(@PathVariable Long id, @Valid @RequestBody BuildingSaveRequest request) {
buildingService.update(id, request);
return Result.success();
@ -68,6 +76,8 @@ public class BuildingController {
* 删除楼栋
*/
@DeleteMapping("/{id}")
@PreAuthorize("hasAuthority('base:building:manage')")
@AuditLog(module = "base", type = "DELETE", description = "删除楼栋")
public Result<Void> delete(@PathVariable Long id) {
buildingService.delete(id);
return Result.success();
@ -77,6 +87,7 @@ public class BuildingController {
* 楼栋树楼栋楼层房间
*/
@GetMapping("/tree")
@PreAuthorize("hasAuthority('base:building:manage')")
public Result<List<BuildingTreeDTO>> tree(@RequestParam(required = false) Long buildingId) {
return Result.success(buildingService.tree(UserContext.getProjectId(), buildingId));
}

View File

@ -2,13 +2,14 @@ package com.pms.base.controller;
import com.pms.base.entity.CamCharge;
import com.pms.base.service.CamChargeService;
import com.pms.common.annotation.AuditLog;
import com.pms.common.response.Result;
import com.pms.common.security.UserContext;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.math.BigDecimal;
import java.util.List;
/**
@ -30,16 +31,19 @@ public class CamChargeController {
* @param chargePeriod 分摊周期 2024-01
*/
@PostMapping("/allocate")
public Result<List<CamCharge>> allocate(@RequestParam BigDecimal totalAmount,
@PreAuthorize("hasAuthority('base:finance:manage')")
@AuditLog(module = "base", type = "CREATE", description = "执行 CAM 分摊")
public Result<List<CamCharge>> allocate(@RequestParam Long totalAmountFen,
@RequestParam String chargePeriod) {
Long projectId = UserContext.getProjectId();
return Result.success(camChargeService.allocateCamCharge(projectId, totalAmount, chargePeriod));
return Result.success(camChargeService.allocateCamCharge(projectId, totalAmountFen, chargePeriod));
}
/**
* 查询当前项目某周期的分摊明细
*/
@GetMapping
@PreAuthorize("hasAuthority('base:finance:manage')")
public Result<List<CamCharge>> list(@RequestParam String period) {
Long projectId = UserContext.getProjectId();
return Result.success(camChargeService.listByProjectAndPeriod(projectId, period));

View File

@ -2,11 +2,14 @@ package com.pms.base.controller;
import com.pms.base.entity.CommunityActivity;
import com.pms.base.service.CommunityActivityService;
import com.pms.common.annotation.AuditLog;
import com.pms.common.response.PageResult;
import com.pms.common.response.Result;
import com.pms.common.security.UserContext;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
@ -28,6 +31,7 @@ public class CommunityActivityController {
* 分页查询活动列表
*/
@GetMapping
@PreAuthorize("hasAuthority('base:activity:manage')")
public Result<PageResult<CommunityActivity>> page(
@RequestParam(required = false) Integer status,
@RequestParam(defaultValue = "1") int page,
@ -39,6 +43,7 @@ public class CommunityActivityController {
* 活动详情
*/
@GetMapping("/{id}")
@PreAuthorize("hasAuthority('base:activity:manage')")
public Result<CommunityActivity> getById(@PathVariable Long id) {
return Result.success(communityActivityService.getById(id));
}
@ -47,7 +52,9 @@ public class CommunityActivityController {
* 创建活动
*/
@PostMapping
public Result<Map<String, Long>> create(@RequestBody CommunityActivity activity) {
@PreAuthorize("hasAuthority('base:activity:manage')")
@AuditLog(module = "base", type = "CREATE", description = "创建社区活动")
public Result<Map<String, Long>> create(@Valid @RequestBody CommunityActivity activity) {
Long id = communityActivityService.create(activity);
Map<String, Long> data = new HashMap<>();
data.put("id", id);
@ -58,7 +65,9 @@ public class CommunityActivityController {
* 更新活动
*/
@PutMapping("/{id}")
public Result<Void> update(@PathVariable Long id, @RequestBody CommunityActivity activity) {
@PreAuthorize("hasAuthority('base:activity:manage')")
@AuditLog(module = "base", type = "UPDATE", description = "更新社区活动")
public Result<Void> update(@PathVariable Long id, @Valid @RequestBody CommunityActivity activity) {
communityActivityService.update(id, activity);
return Result.success();
}
@ -67,6 +76,8 @@ public class CommunityActivityController {
* 报名参加活动
*/
@PostMapping("/{id}/sign-up")
@PreAuthorize("hasAuthority('base:activity:manage')")
@AuditLog(module = "base", type = "CREATE", description = "活动报名")
public Result<Void> signUp(@PathVariable Long id) {
communityActivityService.signUp(id);
return Result.success();
@ -76,6 +87,8 @@ public class CommunityActivityController {
* 删除活动
*/
@DeleteMapping("/{id}")
@PreAuthorize("hasAuthority('base:activity:manage')")
@AuditLog(module = "base", type = "DELETE", description = "删除社区活动")
public Result<Void> delete(@PathVariable Long id) {
communityActivityService.delete(id);
return Result.success();

View File

@ -4,11 +4,13 @@ import com.pms.base.dto.ContractDTO;
import com.pms.base.dto.ContractRenewRequest;
import com.pms.base.dto.ContractSaveRequest;
import com.pms.base.service.ContractService;
import com.pms.common.annotation.AuditLog;
import com.pms.common.response.PageResult;
import com.pms.common.response.Result;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
@ -31,6 +33,7 @@ public class ContractController {
* 合同列表查询分页
*/
@GetMapping
@PreAuthorize("hasAuthority('base:contract:manage')")
public Result<PageResult<ContractDTO>> list(ContractDTO query) {
return Result.success(contractService.page(query));
}
@ -39,6 +42,8 @@ public class ContractController {
* 创建合同
*/
@PostMapping
@PreAuthorize("hasAuthority('base:contract:manage')")
@AuditLog(module = "base", type = "CREATE", description = "创建合同")
public Result<Map<String, Long>> create(@Valid @RequestBody ContractSaveRequest request) {
Long id = contractService.create(request);
Map<String, Long> data = new HashMap<>();
@ -50,6 +55,7 @@ public class ContractController {
* 合同详情
*/
@GetMapping("/{id}")
@PreAuthorize("hasAuthority('base:contract:manage')")
public Result<ContractDTO> getById(@PathVariable Long id) {
return Result.success(contractService.getById(id));
}
@ -58,6 +64,8 @@ public class ContractController {
* 更新合同
*/
@PutMapping("/{id}")
@PreAuthorize("hasAuthority('base:contract:manage')")
@AuditLog(module = "base", type = "UPDATE", description = "更新合同")
public Result<Void> update(@PathVariable Long id, @Valid @RequestBody ContractSaveRequest request) {
contractService.update(id, request);
return Result.success();
@ -67,6 +75,8 @@ public class ContractController {
* 删除合同
*/
@DeleteMapping("/{id}")
@PreAuthorize("hasAuthority('base:contract:manage')")
@AuditLog(module = "base", type = "DELETE", description = "删除合同")
public Result<Void> delete(@PathVariable Long id) {
contractService.delete(id);
return Result.success();
@ -76,6 +86,7 @@ public class ContractController {
* 即将到期合同列表
*/
@GetMapping("/expiring")
@PreAuthorize("hasAuthority('base:contract:manage')")
public Result<List<ContractDTO>> expiring(@RequestParam(defaultValue = "30") int days) {
return Result.success(contractService.expiring(days));
}
@ -84,6 +95,8 @@ public class ContractController {
* 合同续签
*/
@PostMapping("/{id}/renew")
@PreAuthorize("hasAuthority('base:contract:manage')")
@AuditLog(module = "base", type = "CREATE", description = "合同续签")
public Result<Map<String, Long>> renew(@PathVariable Long id, @Valid @RequestBody ContractRenewRequest request) {
Long newId = contractService.renew(id, request);
Map<String, Long> data = new HashMap<>();

View File

@ -3,11 +3,13 @@ package com.pms.base.controller;
import com.pms.base.dto.ContractTypeDTO;
import com.pms.base.dto.ContractTypeSaveRequest;
import com.pms.base.service.ContractTypeService;
import com.pms.common.annotation.AuditLog;
import com.pms.common.response.Result;
import com.pms.common.security.UserContext;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
@ -30,6 +32,7 @@ public class ContractTypeController {
* 合同类型列表
*/
@GetMapping
@PreAuthorize("hasAuthority('base:contract:manage')")
public Result<List<ContractTypeDTO>> list() {
return Result.success(contractTypeService.list(UserContext.getProjectId()));
}
@ -38,6 +41,8 @@ public class ContractTypeController {
* 创建合同类型
*/
@PostMapping
@PreAuthorize("hasAuthority('base:contract:manage')")
@AuditLog(module = "base", type = "CREATE", description = "创建合同类型")
public Result<Map<String, Long>> create(@Valid @RequestBody ContractTypeSaveRequest request) {
Long id = contractTypeService.create(request);
Map<String, Long> data = new HashMap<>();
@ -49,6 +54,8 @@ public class ContractTypeController {
* 更新合同类型
*/
@PutMapping("/{id}")
@PreAuthorize("hasAuthority('base:contract:manage')")
@AuditLog(module = "base", type = "UPDATE", description = "更新合同类型")
public Result<Void> update(@PathVariable Long id, @Valid @RequestBody ContractTypeSaveRequest request) {
contractTypeService.update(id, request);
return Result.success();
@ -58,6 +65,8 @@ public class ContractTypeController {
* 删除合同类型
*/
@DeleteMapping("/{id}")
@PreAuthorize("hasAuthority('base:contract:manage')")
@AuditLog(module = "base", type = "DELETE", description = "删除合同类型")
public Result<Void> delete(@PathVariable Long id) {
contractTypeService.delete(id);
return Result.success();

View File

@ -3,11 +3,13 @@ package com.pms.base.controller;
import com.pms.base.dto.DeviceCategoryDTO;
import com.pms.base.dto.DeviceCategorySaveRequest;
import com.pms.base.service.DeviceCategoryService;
import com.pms.common.annotation.AuditLog;
import com.pms.common.response.Result;
import com.pms.common.security.UserContext;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
@ -30,6 +32,7 @@ public class DeviceCategoryController {
* 设备分类树
*/
@GetMapping("/tree")
@PreAuthorize("hasAuthority('base:device:manage')")
public Result<List<DeviceCategoryDTO>> tree() {
return Result.success(deviceCategoryService.tree(UserContext.getProjectId()));
}
@ -38,6 +41,8 @@ public class DeviceCategoryController {
* 创建设备分类
*/
@PostMapping
@PreAuthorize("hasAuthority('base:device:manage')")
@AuditLog(module = "base", type = "CREATE", description = "创建设备分类")
public Result<Map<String, Long>> create(@Valid @RequestBody DeviceCategorySaveRequest request) {
Long id = deviceCategoryService.create(request);
Map<String, Long> data = new HashMap<>();
@ -49,6 +54,8 @@ public class DeviceCategoryController {
* 更新设备分类
*/
@PutMapping("/{id}")
@PreAuthorize("hasAuthority('base:device:manage')")
@AuditLog(module = "base", type = "UPDATE", description = "更新设备分类")
public Result<Void> update(@PathVariable Long id, @Valid @RequestBody DeviceCategorySaveRequest request) {
deviceCategoryService.update(id, request);
return Result.success();
@ -58,6 +65,8 @@ public class DeviceCategoryController {
* 删除设备分类
*/
@DeleteMapping("/{id}")
@PreAuthorize("hasAuthority('base:device:manage')")
@AuditLog(module = "base", type = "DELETE", description = "删除设备分类")
public Result<Void> delete(@PathVariable Long id) {
deviceCategoryService.delete(id);
return Result.success();

View File

@ -4,11 +4,13 @@ import com.pms.base.dto.DeviceDTO;
import com.pms.base.dto.DeviceSaveRequest;
import com.pms.base.dto.DeviceStatusRequest;
import com.pms.base.service.DeviceService;
import com.pms.common.annotation.AuditLog;
import com.pms.common.response.PageResult;
import com.pms.common.response.Result;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
@ -30,6 +32,7 @@ public class DeviceController {
* 设备列表查询分页
*/
@GetMapping
@PreAuthorize("hasAuthority('base:device:manage')")
public Result<PageResult<DeviceDTO>> list(DeviceDTO query) {
return Result.success(deviceService.page(query));
}
@ -38,6 +41,8 @@ public class DeviceController {
* 创建设备
*/
@PostMapping
@PreAuthorize("hasAuthority('base:device:manage')")
@AuditLog(module = "base", type = "CREATE", description = "创建设备")
public Result<Map<String, Long>> create(@Valid @RequestBody DeviceSaveRequest request) {
Long id = deviceService.create(request);
Map<String, Long> data = new HashMap<>();
@ -49,6 +54,7 @@ public class DeviceController {
* 设备详情
*/
@GetMapping("/{id}")
@PreAuthorize("hasAuthority('base:device:manage')")
public Result<DeviceDTO> getById(@PathVariable Long id) {
return Result.success(deviceService.getById(id));
}
@ -57,6 +63,8 @@ public class DeviceController {
* 更新设备
*/
@PutMapping("/{id}")
@PreAuthorize("hasAuthority('base:device:manage')")
@AuditLog(module = "base", type = "UPDATE", description = "更新设备")
public Result<Void> update(@PathVariable Long id, @Valid @RequestBody DeviceSaveRequest request) {
deviceService.update(id, request);
return Result.success();
@ -66,6 +74,8 @@ public class DeviceController {
* 删除设备
*/
@DeleteMapping("/{id}")
@PreAuthorize("hasAuthority('base:device:manage')")
@AuditLog(module = "base", type = "DELETE", description = "删除设备")
public Result<Void> delete(@PathVariable Long id) {
deviceService.delete(id);
return Result.success();
@ -75,6 +85,8 @@ public class DeviceController {
* 更新设备状态
*/
@PutMapping("/{id}/status")
@PreAuthorize("hasAuthority('base:device:manage')")
@AuditLog(module = "base", type = "UPDATE", description = "更新设备状态")
public Result<Void> updateStatus(@PathVariable Long id, @Valid @RequestBody DeviceStatusRequest request) {
deviceService.updateStatus(id, request.getStatus());
return Result.success();

View File

@ -3,11 +3,13 @@ package com.pms.base.controller;
import com.pms.base.dto.DeviceMaintenanceDTO;
import com.pms.base.dto.DeviceMaintenanceSaveRequest;
import com.pms.base.service.DeviceMaintenanceService;
import com.pms.common.annotation.AuditLog;
import com.pms.common.response.PageResult;
import com.pms.common.response.Result;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
@ -30,6 +32,7 @@ public class DeviceMaintenanceController {
* 维保记录列表查询分页
*/
@GetMapping
@PreAuthorize("hasAuthority('base:device:manage')")
public Result<PageResult<DeviceMaintenanceDTO>> list(DeviceMaintenanceDTO query,
@RequestParam(required = false) Long deviceId) {
return Result.success(maintenanceService.page(query, deviceId));
@ -39,6 +42,8 @@ public class DeviceMaintenanceController {
* 创建维保记录
*/
@PostMapping
@PreAuthorize("hasAuthority('base:device:manage')")
@AuditLog(module = "base", type = "CREATE", description = "创建设备维保记录")
public Result<Map<String, Long>> create(@RequestParam Long deviceId,
@Valid @RequestBody DeviceMaintenanceSaveRequest request) {
Long id = maintenanceService.create(deviceId, request);
@ -51,6 +56,7 @@ public class DeviceMaintenanceController {
* 维保详情
*/
@GetMapping("/{id}")
@PreAuthorize("hasAuthority('base:device:manage')")
public Result<DeviceMaintenanceDTO> getById(@PathVariable Long id) {
return Result.success(maintenanceService.getById(id));
}
@ -59,6 +65,7 @@ public class DeviceMaintenanceController {
* 即将到期维保列表
*/
@GetMapping("/expiring")
@PreAuthorize("hasAuthority('base:device:manage')")
public Result<List<DeviceMaintenanceDTO>> expiring(@RequestParam(defaultValue = "30") int days) {
return Result.success(maintenanceService.expiring(days));
}

View File

@ -2,10 +2,13 @@ package com.pms.base.controller;
import com.pms.base.entity.EnergyMeter;
import com.pms.base.service.EnergyMeterService;
import com.pms.common.annotation.AuditLog;
import com.pms.common.response.Result;
import com.pms.common.security.UserContext;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.math.BigDecimal;
@ -29,6 +32,7 @@ public class EnergyMeterController {
* 表计列表
*/
@GetMapping
@PreAuthorize("hasAuthority('base:energy:manage')")
public Result<List<EnergyMeter>> list() {
return Result.success(energyMeterService.list(UserContext.getProjectId()));
}
@ -37,6 +41,7 @@ public class EnergyMeterController {
* 表计详情
*/
@GetMapping("/{id}")
@PreAuthorize("hasAuthority('base:energy:manage')")
public Result<EnergyMeter> getById(@PathVariable Long id) {
return Result.success(energyMeterService.getById(id));
}
@ -45,7 +50,9 @@ public class EnergyMeterController {
* 创建表计
*/
@PostMapping
public Result<Map<String, Long>> create(@RequestBody EnergyMeter entity) {
@PreAuthorize("hasAuthority('base:energy:manage')")
@AuditLog(module = "base", type = "CREATE", description = "创建能源表计")
public Result<Map<String, Long>> create(@Valid @RequestBody EnergyMeter entity) {
Long id = energyMeterService.create(entity);
Map<String, Long> data = new HashMap<>();
data.put("id", id);
@ -56,7 +63,9 @@ public class EnergyMeterController {
* 更新表计
*/
@PutMapping("/{id}")
public Result<Void> update(@PathVariable Long id, @RequestBody EnergyMeter entity) {
@PreAuthorize("hasAuthority('base:energy:manage')")
@AuditLog(module = "base", type = "UPDATE", description = "更新能源表计")
public Result<Void> update(@PathVariable Long id, @Valid @RequestBody EnergyMeter entity) {
energyMeterService.update(id, entity);
return Result.success();
}
@ -65,6 +74,8 @@ public class EnergyMeterController {
* 删除表计
*/
@DeleteMapping("/{id}")
@PreAuthorize("hasAuthority('base:energy:manage')")
@AuditLog(module = "base", type = "DELETE", description = "删除能源表计")
public Result<Void> delete(@PathVariable Long id) {
energyMeterService.delete(id);
return Result.success();
@ -74,6 +85,8 @@ public class EnergyMeterController {
* 抄表记录首期仅台账预留 IoT 接入点 T4
*/
@PostMapping("/{id}/reading")
@PreAuthorize("hasAuthority('base:energy:manage')")
@AuditLog(module = "base", type = "CREATE", description = "能源表计抄表")
public Result<Void> recordReading(@PathVariable Long id, @RequestParam BigDecimal newReading) {
energyMeterService.recordReading(id, newReading);
return Result.success();

View File

@ -3,11 +3,13 @@ package com.pms.base.controller;
import com.pms.base.dto.EnterpriseDTO;
import com.pms.base.dto.EnterpriseSaveRequest;
import com.pms.base.service.EnterpriseService;
import com.pms.common.annotation.AuditLog;
import com.pms.common.response.PageResult;
import com.pms.common.response.Result;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
@ -29,6 +31,7 @@ public class EnterpriseController {
* 企业列表查询分页
*/
@GetMapping
@PreAuthorize("hasAuthority('base:enterprise:manage')")
public Result<PageResult<EnterpriseDTO>> list(EnterpriseDTO query) {
return Result.success(enterpriseService.page(query));
}
@ -37,6 +40,8 @@ public class EnterpriseController {
* 创建企业
*/
@PostMapping
@PreAuthorize("hasAuthority('base:enterprise:manage')")
@AuditLog(module = "base", type = "CREATE", description = "创建企业", recordParams = false)
public Result<Map<String, Long>> create(@Valid @RequestBody EnterpriseSaveRequest request) {
Long id = enterpriseService.create(request);
Map<String, Long> data = new HashMap<>();
@ -48,6 +53,7 @@ public class EnterpriseController {
* 企业详情
*/
@GetMapping("/{id}")
@PreAuthorize("hasAuthority('base:enterprise:manage')")
public Result<EnterpriseDTO> getById(@PathVariable Long id) {
return Result.success(enterpriseService.getById(id));
}
@ -56,6 +62,8 @@ public class EnterpriseController {
* 更新企业
*/
@PutMapping("/{id}")
@PreAuthorize("hasAuthority('base:enterprise:manage')")
@AuditLog(module = "base", type = "UPDATE", description = "更新企业", recordParams = false)
public Result<Void> update(@PathVariable Long id, @Valid @RequestBody EnterpriseSaveRequest request) {
enterpriseService.update(id, request);
return Result.success();
@ -65,6 +73,8 @@ public class EnterpriseController {
* 删除企业
*/
@DeleteMapping("/{id}")
@PreAuthorize("hasAuthority('base:enterprise:manage')")
@AuditLog(module = "base", type = "DELETE", description = "删除企业")
public Result<Void> delete(@PathVariable Long id) {
enterpriseService.delete(id);
return Result.success();

View File

@ -2,10 +2,13 @@ package com.pms.base.controller;
import com.pms.base.entity.EnterpriseProfile;
import com.pms.base.service.EnterpriseProfileService;
import com.pms.common.annotation.AuditLog;
import com.pms.common.response.Result;
import com.pms.common.security.UserContext;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
@ -28,6 +31,7 @@ public class EnterpriseProfileController {
* 企业画像列表
*/
@GetMapping
@PreAuthorize("hasAuthority('base:enterprise:manage')")
public Result<List<EnterpriseProfile>> list() {
return Result.success(enterpriseProfileService.list(UserContext.getProjectId()));
}
@ -36,6 +40,7 @@ public class EnterpriseProfileController {
* 企业画像详情
*/
@GetMapping("/{id}")
@PreAuthorize("hasAuthority('base:enterprise:manage')")
public Result<EnterpriseProfile> getById(@PathVariable Long id) {
return Result.success(enterpriseProfileService.getById(id));
}
@ -44,7 +49,9 @@ public class EnterpriseProfileController {
* 创建企业画像
*/
@PostMapping
public Result<Map<String, Long>> create(@RequestBody EnterpriseProfile entity) {
@PreAuthorize("hasAuthority('base:enterprise:manage')")
@AuditLog(module = "base", type = "CREATE", description = "创建企业画像", recordParams = false)
public Result<Map<String, Long>> create(@Valid @RequestBody EnterpriseProfile entity) {
Long id = enterpriseProfileService.create(entity);
Map<String, Long> data = new HashMap<>();
data.put("id", id);
@ -55,7 +62,9 @@ public class EnterpriseProfileController {
* 更新企业画像
*/
@PutMapping("/{id}")
public Result<Void> update(@PathVariable Long id, @RequestBody EnterpriseProfile entity) {
@PreAuthorize("hasAuthority('base:enterprise:manage')")
@AuditLog(module = "base", type = "UPDATE", description = "更新企业画像", recordParams = false)
public Result<Void> update(@PathVariable Long id, @Valid @RequestBody EnterpriseProfile entity) {
enterpriseProfileService.update(id, entity);
return Result.success();
}
@ -64,6 +73,8 @@ public class EnterpriseProfileController {
* 删除企业画像
*/
@DeleteMapping("/{id}")
@PreAuthorize("hasAuthority('base:enterprise:manage')")
@AuditLog(module = "base", type = "DELETE", description = "删除企业画像")
public Result<Void> delete(@PathVariable Long id) {
enterpriseProfileService.delete(id);
return Result.success();

View File

@ -2,10 +2,13 @@ package com.pms.base.controller;
import com.pms.base.entity.EnterpriseService;
import com.pms.base.service.EnterpriseServiceService;
import com.pms.common.annotation.AuditLog;
import com.pms.common.response.Result;
import com.pms.common.security.UserContext;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
@ -28,6 +31,7 @@ public class EnterpriseServiceController {
* 服务申请详情
*/
@GetMapping("/{id}")
@PreAuthorize("hasAuthority('base:enterprise:manage')")
public Result<EnterpriseService> getById(@PathVariable Long id) {
return Result.success(enterpriseServiceService.getById(id));
}
@ -36,6 +40,7 @@ public class EnterpriseServiceController {
* 当前项目下企业服务申请列表
*/
@GetMapping
@PreAuthorize("hasAuthority('base:enterprise:manage')")
public Result<List<EnterpriseService>> list() {
return Result.success(enterpriseServiceService.listByProject(UserContext.getProjectId()));
}
@ -44,7 +49,9 @@ public class EnterpriseServiceController {
* 提交服务申请
*/
@PostMapping("/apply")
public Result<Map<String, Long>> apply(@RequestBody EnterpriseService service) {
@PreAuthorize("hasAuthority('base:enterprise:manage')")
@AuditLog(module = "base", type = "CREATE", description = "提交企业服务申请", recordParams = false)
public Result<Map<String, Long>> apply(@Valid @RequestBody EnterpriseService service) {
service.setProjectId(UserContext.getProjectId());
Long id = enterpriseServiceService.apply(service);
Map<String, Long> data = new HashMap<>();
@ -56,6 +63,8 @@ public class EnterpriseServiceController {
* 处理服务申请标记为已完成
*/
@PutMapping("/{id}/process")
@PreAuthorize("hasAuthority('base:enterprise:manage')")
@AuditLog(module = "base", type = "UPDATE", description = "处理企业服务申请", recordParams = false)
public Result<Void> process(@PathVariable Long id,
@RequestParam String handler,
@RequestParam String result) {

View File

@ -3,11 +3,13 @@ package com.pms.base.controller;
import com.pms.base.dto.FloorDTO;
import com.pms.base.dto.FloorSaveRequest;
import com.pms.base.service.FloorService;
import com.pms.common.annotation.AuditLog;
import com.pms.common.response.Result;
import com.pms.common.security.UserContext;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
@ -30,6 +32,7 @@ public class FloorController {
* 楼层列表查询
*/
@GetMapping
@PreAuthorize("hasAuthority('base:floor:manage')")
public Result<List<FloorDTO>> list(@RequestParam(required = false) Long buildingId) {
return Result.success(floorService.list(UserContext.getProjectId(), buildingId));
}
@ -38,6 +41,8 @@ public class FloorController {
* 创建楼层
*/
@PostMapping
@PreAuthorize("hasAuthority('base:floor:manage')")
@AuditLog(module = "base", type = "CREATE", description = "创建楼层")
public Result<Map<String, Long>> create(@Valid @RequestBody FloorSaveRequest request) {
Long id = floorService.create(request);
Map<String, Long> data = new HashMap<>();
@ -49,6 +54,8 @@ public class FloorController {
* 更新楼层
*/
@PutMapping("/{id}")
@PreAuthorize("hasAuthority('base:floor:manage')")
@AuditLog(module = "base", type = "UPDATE", description = "更新楼层")
public Result<Void> update(@PathVariable Long id, @Valid @RequestBody FloorSaveRequest request) {
floorService.update(id, request);
return Result.success();
@ -58,6 +65,8 @@ public class FloorController {
* 删除楼层
*/
@DeleteMapping("/{id}")
@PreAuthorize("hasAuthority('base:floor:manage')")
@AuditLog(module = "base", type = "DELETE", description = "删除楼层")
public Result<Void> delete(@PathVariable Long id) {
floorService.delete(id);
return Result.success();

View File

@ -2,11 +2,14 @@ package com.pms.base.controller;
import com.pms.base.entity.LeaseContract;
import com.pms.base.service.LeaseContractService;
import com.pms.common.annotation.AuditLog;
import com.pms.common.response.Result;
import com.pms.common.security.UserContext;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDate;
@ -30,6 +33,7 @@ public class LeaseContractController {
* 合同详情
*/
@GetMapping("/{id}")
@PreAuthorize("hasAuthority('base:lease:manage')")
public Result<LeaseContract> getById(@PathVariable Long id) {
return Result.success(leaseContractService.getById(id));
}
@ -38,6 +42,7 @@ public class LeaseContractController {
* 当前项目下合同列表
*/
@GetMapping
@PreAuthorize("hasAuthority('base:lease:manage')")
public Result<List<LeaseContract>> list() {
return Result.success(leaseContractService.listByProject(UserContext.getProjectId()));
}
@ -46,7 +51,9 @@ public class LeaseContractController {
* 创建租赁合同
*/
@PostMapping
public Result<Map<String, Long>> create(@RequestBody LeaseContract contract) {
@PreAuthorize("hasAuthority('base:lease:manage')")
@AuditLog(module = "base", type = "CREATE", description = "创建租赁合同")
public Result<Map<String, Long>> create(@Valid @RequestBody LeaseContract contract) {
contract.setProjectId(UserContext.getProjectId());
Long id = leaseContractService.create(contract);
Map<String, Long> data = new HashMap<>();
@ -58,6 +65,8 @@ public class LeaseContractController {
* 续约延长合同结束日期
*/
@PutMapping("/{id}/renew")
@PreAuthorize("hasAuthority('base:lease:manage')")
@AuditLog(module = "base", type = "UPDATE", description = "租赁合同续约")
public Result<Void> renew(@PathVariable Long id,
@RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate newEndDate) {
leaseContractService.renew(id, newEndDate);
@ -68,6 +77,8 @@ public class LeaseContractController {
* 退租
*/
@PutMapping("/{id}/terminate")
@PreAuthorize("hasAuthority('base:lease:manage')")
@AuditLog(module = "base", type = "UPDATE", description = "租赁合同退租")
public Result<Void> terminate(@PathVariable Long id) {
leaseContractService.terminate(id);
return Result.success();

View File

@ -10,6 +10,7 @@ import com.pms.common.response.Result;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
@ -31,6 +32,7 @@ public class LifecycleController {
* 查询当前生命周期阶段R5
*/
@GetMapping("/stage")
@PreAuthorize("hasAuthority('base:lifecycle:manage')")
@AuditLog(module = "base", type = "QUERY", description = "查询生命周期阶段")
public Result<LifecycleStageDTO> getStage(@PathVariable Long projectId) {
return Result.success(lifecycleService.getStage(projectId));
@ -40,6 +42,7 @@ public class LifecycleController {
* 发起阶段流转
*/
@PostMapping("/transition")
@PreAuthorize("hasAuthority('base:lifecycle:manage')")
@AuditLog(module = "base", type = "CREATE", description = "发起阶段流转")
public Result<Map<String, Long>> transition(@PathVariable Long projectId,
@Valid @RequestBody StageTransitionRequest request) {
@ -53,6 +56,7 @@ public class LifecycleController {
* 审批通过
*/
@PostMapping("/approve")
@PreAuthorize("hasAuthority('base:lifecycle:manage')")
@AuditLog(module = "base", type = "UPDATE", description = "审批通过")
public Result<ApprovalCompletedEvent> approve(@PathVariable Long projectId,
@Valid @RequestBody ApprovalActionRequest request) {
@ -63,6 +67,7 @@ public class LifecycleController {
* 审批退回
*/
@PostMapping("/reject")
@PreAuthorize("hasAuthority('base:lifecycle:manage')")
@AuditLog(module = "base", type = "UPDATE", description = "审批退回")
public Result<Void> reject(@PathVariable Long projectId,
@Valid @RequestBody ApprovalActionRequest request) {

View File

@ -2,10 +2,13 @@ package com.pms.base.controller;
import com.pms.base.entity.MeetingRoom;
import com.pms.base.service.MeetingRoomService;
import com.pms.common.annotation.AuditLog;
import com.pms.common.response.Result;
import com.pms.common.security.UserContext;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
@ -28,6 +31,7 @@ public class MeetingRoomController {
* 会议室详情
*/
@GetMapping("/{id}")
@PreAuthorize("hasAuthority('base:meeting:manage')")
public Result<MeetingRoom> getById(@PathVariable Long id) {
return Result.success(meetingRoomService.getById(id));
}
@ -36,6 +40,7 @@ public class MeetingRoomController {
* 当前项目下会议室列表
*/
@GetMapping
@PreAuthorize("hasAuthority('base:meeting:manage')")
public Result<List<MeetingRoom>> list() {
return Result.success(meetingRoomService.listByProject(UserContext.getProjectId()));
}
@ -44,7 +49,9 @@ public class MeetingRoomController {
* 创建会议室
*/
@PostMapping
public Result<Map<String, Long>> create(@RequestBody MeetingRoom room) {
@PreAuthorize("hasAuthority('base:meeting:manage')")
@AuditLog(module = "base", type = "CREATE", description = "创建会议室")
public Result<Map<String, Long>> create(@Valid @RequestBody MeetingRoom room) {
room.setProjectId(UserContext.getProjectId());
Long id = meetingRoomService.create(room);
Map<String, Long> data = new HashMap<>();
@ -56,6 +63,8 @@ public class MeetingRoomController {
* 预订会议室
*/
@PutMapping("/{id}/book")
@PreAuthorize("hasAuthority('base:meeting:manage')")
@AuditLog(module = "base", type = "UPDATE", description = "预订会议室")
public Result<Void> book(@PathVariable Long id) {
meetingRoomService.book(id);
return Result.success();
@ -65,6 +74,8 @@ public class MeetingRoomController {
* 取消预订
*/
@PutMapping("/{id}/cancel")
@PreAuthorize("hasAuthority('base:meeting:manage')")
@AuditLog(module = "base", type = "UPDATE", description = "取消会议室预订")
public Result<Void> cancelBooking(@PathVariable Long id) {
meetingRoomService.cancelBooking(id);
return Result.success();

View File

@ -2,11 +2,14 @@ package com.pms.base.controller;
import com.pms.base.entity.OwnerCommittee;
import com.pms.base.service.OwnerCommitteeService;
import com.pms.common.annotation.AuditLog;
import com.pms.common.response.PageResult;
import com.pms.common.response.Result;
import com.pms.common.security.UserContext;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
@ -28,6 +31,7 @@ public class OwnerCommitteeController {
* 分页查询业委会列表
*/
@GetMapping
@PreAuthorize("hasAuthority('base:committee:manage')")
public Result<PageResult<OwnerCommittee>> page(
@RequestParam(required = false) Integer status,
@RequestParam(defaultValue = "1") int page,
@ -39,6 +43,7 @@ public class OwnerCommitteeController {
* 业委会详情
*/
@GetMapping("/{id}")
@PreAuthorize("hasAuthority('base:committee:manage')")
public Result<OwnerCommittee> getById(@PathVariable Long id) {
return Result.success(ownerCommitteeService.getById(id));
}
@ -47,7 +52,9 @@ public class OwnerCommitteeController {
* 创建业委会
*/
@PostMapping
public Result<Map<String, Long>> create(@RequestBody OwnerCommittee committee) {
@PreAuthorize("hasAuthority('base:committee:manage')")
@AuditLog(module = "base", type = "CREATE", description = "创建业委会", recordParams = false)
public Result<Map<String, Long>> create(@Valid @RequestBody OwnerCommittee committee) {
Long id = ownerCommitteeService.create(committee);
Map<String, Long> data = new HashMap<>();
data.put("id", id);
@ -58,7 +65,9 @@ public class OwnerCommitteeController {
* 更新业委会
*/
@PutMapping("/{id}")
public Result<Void> update(@PathVariable Long id, @RequestBody OwnerCommittee committee) {
@PreAuthorize("hasAuthority('base:committee:manage')")
@AuditLog(module = "base", type = "UPDATE", description = "更新业委会", recordParams = false)
public Result<Void> update(@PathVariable Long id, @Valid @RequestBody OwnerCommittee committee) {
ownerCommitteeService.update(id, committee);
return Result.success();
}
@ -67,6 +76,8 @@ public class OwnerCommitteeController {
* 删除业委会
*/
@DeleteMapping("/{id}")
@PreAuthorize("hasAuthority('base:committee:manage')")
@AuditLog(module = "base", type = "DELETE", description = "删除业委会")
public Result<Void> delete(@PathVariable Long id) {
ownerCommitteeService.delete(id);
return Result.success();

View File

@ -6,12 +6,14 @@ import com.pms.base.dto.OwnerRoomDTO;
import com.pms.base.dto.OwnerSaveRequest;
import com.pms.base.listener.OwnerImportListener;
import com.pms.base.service.OwnerService;
import com.pms.common.annotation.AuditLog;
import com.pms.common.response.PageResult;
import com.pms.common.response.Result;
import com.alibaba.excel.EasyExcel;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
@ -35,6 +37,7 @@ public class OwnerController {
* 业主列表查询分页
*/
@GetMapping
@PreAuthorize("hasAuthority('base:owner:manage')")
public Result<PageResult<OwnerDTO>> list(OwnerDTO query) {
return Result.success(ownerService.page(query));
}
@ -43,6 +46,8 @@ public class OwnerController {
* 创建业主
*/
@PostMapping
@PreAuthorize("hasAuthority('base:owner:manage')")
@AuditLog(module = "base", type = "CREATE", description = "创建业主", recordParams = false)
public Result<Map<String, Long>> create(@Valid @RequestBody OwnerSaveRequest request) {
Long id = ownerService.create(request);
Map<String, Long> data = new HashMap<>();
@ -54,6 +59,7 @@ public class OwnerController {
* 业主详情含房产信息
*/
@GetMapping("/{id}")
@PreAuthorize("hasAuthority('base:owner:manage')")
public Result<OwnerDTO> getById(@PathVariable Long id) {
return Result.success(ownerService.getById(id));
}
@ -62,6 +68,8 @@ public class OwnerController {
* 更新业主
*/
@PutMapping("/{id}")
@PreAuthorize("hasAuthority('base:owner:manage')")
@AuditLog(module = "base", type = "UPDATE", description = "更新业主", recordParams = false)
public Result<Void> update(@PathVariable Long id, @Valid @RequestBody OwnerSaveRequest request) {
ownerService.update(id, request);
return Result.success();
@ -71,6 +79,8 @@ public class OwnerController {
* 删除业主
*/
@DeleteMapping("/{id}")
@PreAuthorize("hasAuthority('base:owner:manage')")
@AuditLog(module = "base", type = "DELETE", description = "删除业主")
public Result<Void> delete(@PathVariable Long id) {
ownerService.delete(id);
return Result.success();
@ -80,6 +90,7 @@ public class OwnerController {
* 业主房产列表
*/
@GetMapping("/{id}/rooms")
@PreAuthorize("hasAuthority('base:owner:manage')")
public Result<List<OwnerRoomDTO>> getOwnerRooms(@PathVariable Long id) {
return Result.success(ownerService.getOwnerRooms(id));
}
@ -88,6 +99,8 @@ public class OwnerController {
* 批量导入业主Excel上传
*/
@PostMapping("/import")
@PreAuthorize("hasAuthority('base:owner:manage')")
@AuditLog(module = "base", type = "CREATE", description = "导入业主", recordParams = false)
public Result<Map<String, Object>> importOwners(@RequestParam("file") MultipartFile file) {
if (file.isEmpty()) {
return Result.error(400, "请选择文件");

View File

@ -3,11 +3,13 @@ package com.pms.base.controller;
import com.pms.base.dto.OwnerRoomDTO;
import com.pms.base.dto.OwnerRoomRelRequest;
import com.pms.base.service.OwnerRoomService;
import com.pms.common.annotation.AuditLog;
import com.pms.common.response.Result;
import com.pms.common.security.UserContext;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
@ -30,6 +32,7 @@ public class OwnerRoomController {
* 业主房间关联列表
*/
@GetMapping
@PreAuthorize("hasAuthority('base:owner:manage')")
public Result<List<OwnerRoomDTO>> list(@RequestParam(required = false) Long ownerId,
@RequestParam(required = false) Long roomId) {
return Result.success(ownerRoomService.list(UserContext.getProjectId(), ownerId, roomId));
@ -39,6 +42,8 @@ public class OwnerRoomController {
* 创建业主房间关联
*/
@PostMapping
@PreAuthorize("hasAuthority('base:owner:manage')")
@AuditLog(module = "base", type = "CREATE", description = "创建业主房间关联", recordParams = false)
public Result<Map<String, Long>> create(@Valid @RequestBody OwnerRoomRelRequest request) {
Long id = ownerRoomService.create(request);
Map<String, Long> data = new HashMap<>();
@ -50,6 +55,8 @@ public class OwnerRoomController {
* 删除业主房间关联
*/
@DeleteMapping("/{id}")
@PreAuthorize("hasAuthority('base:owner:manage')")
@AuditLog(module = "base", type = "DELETE", description = "删除业主房间关联")
public Result<Void> delete(@PathVariable Long id) {
ownerRoomService.delete(id);
return Result.success();

View File

@ -5,11 +5,13 @@ import com.pms.base.dto.ProjectInitRequest;
import com.pms.base.dto.ProjectSaveRequest;
import com.pms.base.service.ProjectInitService;
import com.pms.base.service.ProjectService;
import com.pms.common.annotation.AuditLog;
import com.pms.common.response.PageResult;
import com.pms.common.response.Result;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
@ -33,6 +35,7 @@ public class ProjectController {
* 项目列表查询分页
*/
@GetMapping
@PreAuthorize("hasAuthority('base:project:manage')")
public Result<PageResult<ProjectDTO>> list(ProjectDTO query) {
return Result.success(projectService.page(query));
}
@ -41,6 +44,8 @@ public class ProjectController {
* 创建项目
*/
@PostMapping
@PreAuthorize("hasAuthority('base:project:manage')")
@AuditLog(module = "base", type = "CREATE", description = "创建项目", recordParams = false)
public Result<Map<String, Long>> create(@Valid @RequestBody ProjectSaveRequest request) {
Long id = projectService.create(request);
Map<String, Long> data = new HashMap<>();
@ -52,6 +57,7 @@ public class ProjectController {
* 获取全部项目下拉选择用
*/
@GetMapping("/all")
@PreAuthorize("hasAuthority('base:project:manage')")
public Result<List<ProjectDTO>> listAll() {
return Result.success(projectService.listAll());
}
@ -60,6 +66,7 @@ public class ProjectController {
* 项目详情
*/
@GetMapping("/{id}")
@PreAuthorize("hasAuthority('base:project:manage')")
public Result<ProjectDTO> getById(@PathVariable Long id) {
return Result.success(projectService.getById(id));
}
@ -68,6 +75,8 @@ public class ProjectController {
* 更新项目
*/
@PutMapping("/{id}")
@PreAuthorize("hasAuthority('base:project:manage')")
@AuditLog(module = "base", type = "UPDATE", description = "更新项目", recordParams = false)
public Result<Void> update(@PathVariable Long id, @Valid @RequestBody ProjectSaveRequest request) {
projectService.update(id, request);
return Result.success();
@ -77,6 +86,8 @@ public class ProjectController {
* 删除项目
*/
@DeleteMapping("/{id}")
@PreAuthorize("hasAuthority('base:project:manage')")
@AuditLog(module = "base", type = "DELETE", description = "删除项目")
public Result<Void> delete(@PathVariable Long id) {
projectService.delete(id);
return Result.success();
@ -87,6 +98,8 @@ public class ProjectController {
* 为项目关联业态能力包支持幂等重复调用
*/
@PostMapping("/{id}/init")
@PreAuthorize("hasAuthority('base:project:manage')")
@AuditLog(module = "base", type = "UPDATE", description = "初始化项目能力包")
public Result<Void> initProject(@PathVariable Long id, @Valid @RequestBody ProjectInitRequest request) {
projectInitService.initProject(id, request.getPackageCodes());
return Result.success(null);

View File

@ -2,14 +2,16 @@ package com.pms.base.controller;
import com.pms.base.entity.PublicRevenue;
import com.pms.base.service.PublicRevenueService;
import com.pms.common.annotation.AuditLog;
import com.pms.common.response.PageResult;
import com.pms.common.response.Result;
import com.pms.common.security.UserContext;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.math.BigDecimal;
import java.util.HashMap;
import java.util.Map;
@ -29,6 +31,7 @@ public class PublicRevenueController {
* 分页查询公共收益
*/
@GetMapping
@PreAuthorize("hasAuthority('base:revenue:manage')")
public Result<PageResult<PublicRevenue>> page(
@RequestParam(required = false) String period,
@RequestParam(defaultValue = "1") int page,
@ -40,6 +43,7 @@ public class PublicRevenueController {
* 公共收益详情
*/
@GetMapping("/{id}")
@PreAuthorize("hasAuthority('base:revenue:manage')")
public Result<PublicRevenue> getById(@PathVariable Long id) {
return Result.success(publicRevenueService.getById(id));
}
@ -48,7 +52,9 @@ public class PublicRevenueController {
* 录入公共收益
*/
@PostMapping
public Result<Map<String, Long>> create(@RequestBody PublicRevenue revenue) {
@PreAuthorize("hasAuthority('base:revenue:manage')")
@AuditLog(module = "base", type = "CREATE", description = "录入公共收益")
public Result<Map<String, Long>> create(@Valid @RequestBody PublicRevenue revenue) {
Long id = publicRevenueService.create(revenue);
Map<String, Long> data = new HashMap<>();
data.put("id", id);
@ -59,7 +65,9 @@ public class PublicRevenueController {
* 更新公共收益
*/
@PutMapping("/{id}")
public Result<Void> update(@PathVariable Long id, @RequestBody PublicRevenue revenue) {
@PreAuthorize("hasAuthority('base:revenue:manage')")
@AuditLog(module = "base", type = "UPDATE", description = "更新公共收益")
public Result<Void> update(@PathVariable Long id, @Valid @RequestBody PublicRevenue revenue) {
publicRevenueService.update(id, revenue);
return Result.success();
}
@ -70,8 +78,9 @@ public class PublicRevenueController {
* @param period 周期 2025-06
*/
@GetMapping("/summary")
@PreAuthorize("hasAuthority('base:revenue:manage')")
public Result<Map<String, Object>> monthlySummary(@RequestParam String period) {
BigDecimal total = publicRevenueService.monthlySummary(UserContext.getProjectId(), period);
Long total = publicRevenueService.monthlySummary(UserContext.getProjectId(), period);
Map<String, Object> data = new HashMap<>();
data.put("period", period);
data.put("totalAmount", total);
@ -82,6 +91,8 @@ public class PublicRevenueController {
* 删除公共收益
*/
@DeleteMapping("/{id}")
@PreAuthorize("hasAuthority('base:revenue:manage')")
@AuditLog(module = "base", type = "DELETE", description = "删除公共收益")
public Result<Void> delete(@PathVariable Long id) {
publicRevenueService.delete(id);
return Result.success();

View File

@ -2,11 +2,14 @@ package com.pms.base.controller;
import com.pms.base.entity.Renovation;
import com.pms.base.service.RenovationService;
import com.pms.common.annotation.AuditLog;
import com.pms.common.response.PageResult;
import com.pms.common.response.Result;
import com.pms.common.security.UserContext;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
@ -28,6 +31,7 @@ public class RenovationController {
* 分页查询装修记录
*/
@GetMapping
@PreAuthorize("hasAuthority('base:renovation:manage')")
public Result<PageResult<Renovation>> page(
@RequestParam(required = false) Integer status,
@RequestParam(defaultValue = "1") int page,
@ -39,6 +43,7 @@ public class RenovationController {
* 装修记录详情
*/
@GetMapping("/{id}")
@PreAuthorize("hasAuthority('base:renovation:manage')")
public Result<Renovation> getById(@PathVariable Long id) {
return Result.success(renovationService.getById(id));
}
@ -47,7 +52,9 @@ public class RenovationController {
* 申请装修
*/
@PostMapping("/apply")
public Result<Map<String, Long>> apply(@RequestBody Renovation renovation) {
@PreAuthorize("hasAuthority('base:renovation:manage')")
@AuditLog(module = "base", type = "CREATE", description = "申请装修", recordParams = false)
public Result<Map<String, Long>> apply(@Valid @RequestBody Renovation renovation) {
Long id = renovationService.applyRenovation(renovation);
Map<String, Long> data = new HashMap<>();
data.put("id", id);
@ -63,6 +70,8 @@ public class RenovationController {
* @param comment 审批意见
*/
@PostMapping("/{id}/approve")
@PreAuthorize("hasAuthority('base:renovation:manage')")
@AuditLog(module = "base", type = "UPDATE", description = "审批装修申请")
public Result<Void> approve(@PathVariable Long id,
@RequestParam boolean approved,
@RequestParam(required = false) String permitType,
@ -75,6 +84,8 @@ public class RenovationController {
* 开始施工
*/
@PostMapping("/{id}/start")
@PreAuthorize("hasAuthority('base:renovation:manage')")
@AuditLog(module = "base", type = "UPDATE", description = "开始施工")
public Result<Void> startConstruction(@PathVariable Long id) {
renovationService.startConstruction(id);
return Result.success();
@ -84,6 +95,8 @@ public class RenovationController {
* 装修完成
*/
@PostMapping("/{id}/complete")
@PreAuthorize("hasAuthority('base:renovation:manage')")
@AuditLog(module = "base", type = "UPDATE", description = "装修完成")
public Result<Void> complete(@PathVariable Long id) {
renovationService.complete(id);
return Result.success();
@ -93,6 +106,8 @@ public class RenovationController {
* 删除装修记录
*/
@DeleteMapping("/{id}")
@PreAuthorize("hasAuthority('base:renovation:manage')")
@AuditLog(module = "base", type = "DELETE", description = "删除装修记录")
public Result<Void> delete(@PathVariable Long id) {
renovationService.delete(id);
return Result.success();

View File

@ -6,6 +6,7 @@ import com.pms.base.dto.RoomImportData;
import com.pms.base.dto.RoomSaveRequest;
import com.pms.base.listener.RoomImportListener;
import com.pms.base.service.RoomService;
import com.pms.common.annotation.AuditLog;
import com.pms.common.response.PageResult;
import com.pms.common.response.Result;
import com.pms.common.security.UserContext;
@ -13,6 +14,7 @@ import com.alibaba.excel.EasyExcel;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
@ -36,6 +38,7 @@ public class RoomController {
* 房间列表查询分页支持多条件筛选
*/
@GetMapping
@PreAuthorize("hasAuthority('base:room:manage')")
public Result<PageResult<RoomDTO>> list(RoomDTO query) {
return Result.success(roomService.page(query));
}
@ -44,6 +47,8 @@ public class RoomController {
* 创建房间
*/
@PostMapping
@PreAuthorize("hasAuthority('base:room:manage')")
@AuditLog(module = "base", type = "CREATE", description = "创建房间")
public Result<Map<String, Long>> create(@Valid @RequestBody RoomSaveRequest request) {
Long id = roomService.create(request);
Map<String, Long> data = new HashMap<>();
@ -55,6 +60,8 @@ public class RoomController {
* 批量创建房间
*/
@PostMapping("/batch")
@PreAuthorize("hasAuthority('base:room:manage')")
@AuditLog(module = "base", type = "CREATE", description = "批量创建房间")
public Result<Map<String, Object>> batchCreate(@Valid @RequestBody List<RoomSaveRequest> rooms) {
List<Long> ids = roomService.batchCreate(rooms);
Map<String, Object> data = new HashMap<>();
@ -67,6 +74,7 @@ public class RoomController {
* 房间详情
*/
@GetMapping("/{id}")
@PreAuthorize("hasAuthority('base:room:manage')")
public Result<RoomDTO> getById(@PathVariable Long id) {
return Result.success(roomService.getById(id));
}
@ -75,6 +83,8 @@ public class RoomController {
* 更新房间
*/
@PutMapping("/{id}")
@PreAuthorize("hasAuthority('base:room:manage')")
@AuditLog(module = "base", type = "UPDATE", description = "更新房间")
public Result<Void> update(@PathVariable Long id, @Valid @RequestBody RoomSaveRequest request) {
roomService.update(id, request);
return Result.success();
@ -84,6 +94,8 @@ public class RoomController {
* 删除房间
*/
@DeleteMapping("/{id}")
@PreAuthorize("hasAuthority('base:room:manage')")
@AuditLog(module = "base", type = "DELETE", description = "删除房间")
public Result<Void> delete(@PathVariable Long id) {
roomService.delete(id);
return Result.success();
@ -93,6 +105,7 @@ public class RoomController {
* 房间树项目楼栋楼层房间
*/
@GetMapping("/tree")
@PreAuthorize("hasAuthority('base:room:manage')")
public Result<List<BuildingTreeDTO>> tree() {
return Result.success(roomService.tree(UserContext.getProjectId()));
}
@ -101,6 +114,8 @@ public class RoomController {
* 批量导入房间Excel上传
*/
@PostMapping("/import")
@PreAuthorize("hasAuthority('base:room:manage')")
@AuditLog(module = "base", type = "CREATE", description = "导入房间")
public Result<Map<String, Object>> importRooms(@RequestParam("file") MultipartFile file) {
if (file.isEmpty()) {
return Result.error(400, "请选择文件");

View File

@ -2,10 +2,13 @@ package com.pms.base.controller;
import com.pms.base.entity.SafetyInspection;
import com.pms.base.service.SafetyInspectionService;
import com.pms.common.annotation.AuditLog;
import com.pms.common.response.Result;
import com.pms.common.security.UserContext;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
@ -28,6 +31,7 @@ public class SafetyInspectionController {
* 安全检查列表
*/
@GetMapping
@PreAuthorize("hasAuthority('base:inspection:manage')")
public Result<List<SafetyInspection>> list() {
return Result.success(safetyInspectionService.list(UserContext.getProjectId()));
}
@ -36,6 +40,7 @@ public class SafetyInspectionController {
* 安全检查详情
*/
@GetMapping("/{id}")
@PreAuthorize("hasAuthority('base:inspection:manage')")
public Result<SafetyInspection> getById(@PathVariable Long id) {
return Result.success(safetyInspectionService.getById(id));
}
@ -44,7 +49,9 @@ public class SafetyInspectionController {
* 创建安全检查计划
*/
@PostMapping
public Result<Map<String, Long>> create(@RequestBody SafetyInspection entity) {
@PreAuthorize("hasAuthority('base:inspection:manage')")
@AuditLog(module = "base", type = "CREATE", description = "创建安全检查计划")
public Result<Map<String, Long>> create(@Valid @RequestBody SafetyInspection entity) {
Long id = safetyInspectionService.create(entity);
Map<String, Long> data = new HashMap<>();
data.put("id", id);
@ -55,7 +62,9 @@ public class SafetyInspectionController {
* 更新安全检查
*/
@PutMapping("/{id}")
public Result<Void> update(@PathVariable Long id, @RequestBody SafetyInspection entity) {
@PreAuthorize("hasAuthority('base:inspection:manage')")
@AuditLog(module = "base", type = "UPDATE", description = "更新安全检查")
public Result<Void> update(@PathVariable Long id, @Valid @RequestBody SafetyInspection entity) {
safetyInspectionService.update(id, entity);
return Result.success();
}
@ -64,6 +73,8 @@ public class SafetyInspectionController {
* 删除安全检查
*/
@DeleteMapping("/{id}")
@PreAuthorize("hasAuthority('base:inspection:manage')")
@AuditLog(module = "base", type = "DELETE", description = "删除安全检查")
public Result<Void> delete(@PathVariable Long id) {
safetyInspectionService.delete(id);
return Result.success();
@ -73,6 +84,8 @@ public class SafetyInspectionController {
* 执行安全检查计划中 -> 执行中
*/
@PostMapping("/{id}/execute")
@PreAuthorize("hasAuthority('base:inspection:manage')")
@AuditLog(module = "base", type = "UPDATE", description = "执行安全检查")
public Result<Void> execute(@PathVariable Long id) {
safetyInspectionService.execute(id);
return Result.success();
@ -82,6 +95,8 @@ public class SafetyInspectionController {
* 上报发现执行中 -> 已上报闭环
*/
@PostMapping("/{id}/report")
@PreAuthorize("hasAuthority('base:inspection:manage')")
@AuditLog(module = "base", type = "UPDATE", description = "上报安全检查发现")
public Result<Void> reportFindings(@PathVariable Long id,
@RequestParam String result,
@RequestParam(required = false) String finding) {

View File

@ -3,11 +3,13 @@ package com.pms.base.controller;
import com.pms.base.dto.TenantDTO;
import com.pms.base.dto.TenantSaveRequest;
import com.pms.base.service.TenantService;
import com.pms.common.annotation.AuditLog;
import com.pms.common.response.PageResult;
import com.pms.common.response.Result;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
@ -29,6 +31,7 @@ public class TenantController {
* 租户列表查询分页
*/
@GetMapping
@PreAuthorize("hasAuthority('base:tenant:manage')")
public Result<PageResult<TenantDTO>> list(TenantDTO query) {
return Result.success(tenantService.page(query));
}
@ -37,6 +40,8 @@ public class TenantController {
* 创建租户
*/
@PostMapping
@PreAuthorize("hasAuthority('base:tenant:manage')")
@AuditLog(module = "base", type = "CREATE", description = "创建租户", recordParams = false)
public Result<Map<String, Long>> create(@Valid @RequestBody TenantSaveRequest request) {
Long id = tenantService.create(request);
Map<String, Long> data = new HashMap<>();
@ -48,6 +53,8 @@ public class TenantController {
* 更新租户
*/
@PutMapping("/{id}")
@PreAuthorize("hasAuthority('base:tenant:manage')")
@AuditLog(module = "base", type = "UPDATE", description = "更新租户", recordParams = false)
public Result<Void> update(@PathVariable Long id, @Valid @RequestBody TenantSaveRequest request) {
tenantService.update(id, request);
return Result.success();
@ -57,6 +64,8 @@ public class TenantController {
* 删除租户
*/
@DeleteMapping("/{id}")
@PreAuthorize("hasAuthority('base:tenant:manage')")
@AuditLog(module = "base", type = "DELETE", description = "删除租户")
public Result<Void> delete(@PathVariable Long id) {
tenantService.delete(id);
return Result.success();

View File

@ -2,10 +2,13 @@ package com.pms.base.controller;
import com.pms.base.entity.WorkshopLease;
import com.pms.base.service.WorkshopLeaseService;
import com.pms.common.annotation.AuditLog;
import com.pms.common.response.Result;
import com.pms.common.security.UserContext;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
@ -28,6 +31,7 @@ public class WorkshopLeaseController {
* 厂房租赁列表
*/
@GetMapping
@PreAuthorize("hasAuthority('base:workshop:manage')")
public Result<List<WorkshopLease>> list() {
return Result.success(workshopLeaseService.list(UserContext.getProjectId()));
}
@ -36,6 +40,7 @@ public class WorkshopLeaseController {
* 厂房租赁详情
*/
@GetMapping("/{id}")
@PreAuthorize("hasAuthority('base:workshop:manage')")
public Result<WorkshopLease> getById(@PathVariable Long id) {
return Result.success(workshopLeaseService.getById(id));
}
@ -44,7 +49,9 @@ public class WorkshopLeaseController {
* 创建厂房租赁
*/
@PostMapping
public Result<Map<String, Long>> create(@RequestBody WorkshopLease entity) {
@PreAuthorize("hasAuthority('base:workshop:manage')")
@AuditLog(module = "base", type = "CREATE", description = "创建厂房租赁", recordParams = false)
public Result<Map<String, Long>> create(@Valid @RequestBody WorkshopLease entity) {
Long id = workshopLeaseService.create(entity);
Map<String, Long> data = new HashMap<>();
data.put("id", id);
@ -55,7 +62,9 @@ public class WorkshopLeaseController {
* 更新厂房租赁
*/
@PutMapping("/{id}")
public Result<Void> update(@PathVariable Long id, @RequestBody WorkshopLease entity) {
@PreAuthorize("hasAuthority('base:workshop:manage')")
@AuditLog(module = "base", type = "UPDATE", description = "更新厂房租赁", recordParams = false)
public Result<Void> update(@PathVariable Long id, @Valid @RequestBody WorkshopLease entity) {
workshopLeaseService.update(id, entity);
return Result.success();
}
@ -64,6 +73,8 @@ public class WorkshopLeaseController {
* 删除厂房租赁
*/
@DeleteMapping("/{id}")
@PreAuthorize("hasAuthority('base:workshop:manage')")
@AuditLog(module = "base", type = "DELETE", description = "删除厂房租赁")
public Result<Void> delete(@PathVariable Long id) {
workshopLeaseService.delete(id);
return Result.success();
@ -73,6 +84,8 @@ public class WorkshopLeaseController {
* 生成租金账单
*/
@PostMapping("/{id}/rent-bill")
@PreAuthorize("hasAuthority('base:workshop:manage')")
@AuditLog(module = "base", type = "CREATE", description = "生成租金账单")
public Result<Map<String, String>> generateRentBill(@PathVariable Long id) {
String billNo = workshopLeaseService.generateRentBill(id);
Map<String, String> data = new HashMap<>();

View File

@ -4,7 +4,6 @@ import lombok.Data;
import lombok.EqualsAndHashCode;
import java.io.Serializable;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
@ -30,8 +29,8 @@ public class ContractDTO extends PageQuery implements Serializable {
private LocalDate startDate;
private LocalDate endDate;
private LocalDate signDate;
private BigDecimal amount;
private BigDecimal deposit;
private Long amountFen;
private Long depositFen;
private String paymentCycle;
private String attachment;
private Integer status;

View File

@ -1,10 +1,10 @@
package com.pms.base.dto;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.PositiveOrZero;
import lombok.Data;
import java.io.Serializable;
import java.math.BigDecimal;
import java.time.LocalDate;
/**
@ -16,6 +16,7 @@ public class ContractRenewRequest implements Serializable {
@NotNull(message = "新结束日期不能为空")
private LocalDate newEndDate;
private BigDecimal amount;
@PositiveOrZero(message = "合同金额必须大于等于0")
private Long amountFen;
private LocalDate signDate;
}

View File

@ -2,10 +2,10 @@ package com.pms.base.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.PositiveOrZero;
import lombok.Data;
import java.io.Serializable;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
@ -42,8 +42,13 @@ public class ContractSaveRequest implements Serializable {
private LocalDate endDate;
private LocalDate signDate;
private BigDecimal amount;
private BigDecimal deposit;
@PositiveOrZero(message = "合同金额必须大于等于0")
private Long amountFen;
@PositiveOrZero(message = "押金必须大于等于0")
private Long depositFen;
private String paymentCycle;
private String attachment;
private Integer status;

View File

@ -4,7 +4,6 @@ import lombok.Data;
import lombok.EqualsAndHashCode;
import java.io.Serializable;
import java.math.BigDecimal;
import java.time.LocalDate;
/**
@ -34,7 +33,7 @@ public class DeviceDTO extends PageQuery implements Serializable {
private LocalDate installDate;
private LocalDate warrantyUntil;
private String supplier;
private BigDecimal price;
private Long priceFen;
private String qrCode;
private Integer status;
private String remark;

View File

@ -4,7 +4,6 @@ import lombok.Data;
import lombok.EqualsAndHashCode;
import java.io.Serializable;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
@ -23,7 +22,7 @@ public class DeviceMaintenanceDTO extends PageQuery implements Serializable {
private LocalDate maintenanceDate;
private LocalDate planDate;
private String handler;
private BigDecimal cost;
private Long costFen;
private String result;
private String images;
private List<String> imageList;

View File

@ -1,10 +1,10 @@
package com.pms.base.dto;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.PositiveOrZero;
import lombok.Data;
import java.io.Serializable;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
@ -22,7 +22,10 @@ public class DeviceMaintenanceSaveRequest implements Serializable {
private LocalDate planDate;
private String handler;
private BigDecimal cost;
@PositiveOrZero(message = "维保费用必须大于等于0")
private Long costFen;
private String result;
private List<String> images;
private Integer status;

View File

@ -2,10 +2,10 @@ package com.pms.base.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.PositiveOrZero;
import lombok.Data;
import java.io.Serializable;
import java.math.BigDecimal;
import java.time.LocalDate;
/**
@ -36,7 +36,10 @@ public class DeviceSaveRequest implements Serializable {
private LocalDate installDate;
private LocalDate warrantyUntil;
private String supplier;
private BigDecimal price;
@PositiveOrZero(message = "设备价格必须大于等于0")
private Long priceFen;
private Integer status;
private String remark;
}

View File

@ -1,6 +1,9 @@
package com.pms.base.dto;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import lombok.Data;
import java.io.Serializable;
@ -12,16 +15,35 @@ import java.io.Serializable;
public class EnterpriseSaveRequest implements Serializable {
@NotBlank(message = "企业名称不能为空")
@Size(max = 128, message = "企业名称长度不能超过128")
private String enterpriseName;
@Size(max = 32, message = "统一社会信用代码长度不能超过32")
private String creditCode;
@Size(max = 32, message = "法人姓名长度不能超过32")
private String legalPerson;
@Size(max = 32, message = "联系人长度不能超过32")
private String contactPerson;
@Pattern(regexp = "^1[3-9]\\d{9}$", message = "联系电话格式不正确")
private String contactPhone;
@Email(message = "邮箱格式不正确")
@Size(max = 128, message = "邮箱长度不能超过128")
private String email;
@Size(max = 255, message = "地址长度不能超过255")
private String address;
@Size(max = 255, message = "经营范围长度不能超过255")
private String businessScope;
@Size(max = 500, message = "承租房间ID列表长度不能超过500")
private String roomIds;
private Integer status;
@Size(max = 255, message = "备注长度不能超过255")
private String remark;
}

View File

@ -1,6 +1,9 @@
package com.pms.base.dto;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import lombok.Data;
import java.io.Serializable;
@ -11,20 +14,38 @@ import java.io.Serializable;
@Data
public class OwnerSaveRequest implements Serializable {
@Size(max = 32, message = "业主编码长度不能超过32")
private String ownerCode;
@NotBlank(message = "业主姓名不能为空")
@Size(max = 32, message = "业主姓名长度不能超过32")
private String ownerName;
private Integer gender;
private Integer idType;
@Pattern(regexp = "^\\d{17}[\\dXx]$", message = "身份证号格式不正确")
private String idNo;
@Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不正确")
private String phone;
@Email(message = "邮箱格式不正确")
@Size(max = 128, message = "邮箱长度不能超过128")
private String email;
@Size(max = 255, message = "地址长度不能超过255")
private String address;
@Size(max = 32, message = "紧急联系人长度不能超过32")
private String emergencyContact;
@Pattern(regexp = "^1[3-9]\\d{9}$", message = "紧急联系电话格式不正确")
private String emergencyPhone;
private Integer status;
@Size(max = 255, message = "备注长度不能超过255")
private String remark;
/** 项目ID管理员无项目上下文时由前端传入 */

View File

@ -1,6 +1,8 @@
package com.pms.base.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import lombok.Data;
import java.io.Serializable;
@ -13,24 +15,43 @@ import java.math.BigDecimal;
public class ProjectSaveRequest implements Serializable {
@NotBlank(message = "项目编码不能为空")
@Size(max = 32, message = "项目编码长度不能超过32")
private String projectCode;
@NotBlank(message = "项目名称不能为空")
@Size(max = 128, message = "项目名称长度不能超过128")
private String projectName;
@Size(max = 64, message = "项目简称长度不能超过64")
private String shortName;
@Size(max = 32, message = "省份长度不能超过32")
private String province;
@Size(max = 32, message = "城市长度不能超过32")
private String city;
@Size(max = 32, message = "区县长度不能超过32")
private String district;
@Size(max = 255, message = "地址长度不能超过255")
private String address;
private BigDecimal longitude;
private BigDecimal latitude;
private BigDecimal coverArea;
private BigDecimal buildingArea;
private BigDecimal greenRate;
@Size(max = 128, message = "物业公司长度不能超过128")
private String propertyCompany;
@Size(max = 32, message = "联系人长度不能超过32")
private String contactPerson;
@Pattern(regexp = "^1[3-9]\\d{9}$", message = "联系电话格式不正确")
private String contactPhone;
private String logo;
private Integer status;
private Integer sort;

View File

@ -1,6 +1,9 @@
package com.pms.base.dto;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import lombok.Data;
import java.io.Serializable;
@ -12,14 +15,27 @@ import java.io.Serializable;
public class TenantSaveRequest implements Serializable {
@NotBlank(message = "租户名称不能为空")
@Size(max = 64, message = "租户名称长度不能超过64")
private String tenantName;
private Integer tenantType;
private Integer idType;
@Pattern(regexp = "^\\d{17}[\\dXx]$", message = "身份证号格式不正确")
private String idNo;
@Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不正确")
private String phone;
@Email(message = "邮箱格式不正确")
@Size(max = 128, message = "邮箱长度不能超过128")
private String email;
@Size(max = 255, message = "地址长度不能超过255")
private String address;
private Integer status;
@Size(max = 255, message = "备注长度不能超过255")
private String remark;
}

View File

@ -1,5 +1,6 @@
package com.pms.base.entity;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.annotation.Version;
import com.pms.common.entity.BaseEntity;
@ -22,11 +23,13 @@ public class CamCharge extends BaseEntity {
/** 分摊周期(如 2024-01 */
private String chargePeriod;
/** 本期公共费用总额 */
private BigDecimal totalAmount;
/** 本期公共费用总额(分) */
@TableField("total_amount")
private Long totalAmountFen;
/** 分摊给该合同的金额 */
private BigDecimal allocatedAmount;
/** 分摊给该合同的金额(分) */
@TableField("allocated_amount")
private Long allocatedAmountFen;
/** 分摊比例面积占比0~1 */
private BigDecimal ratio;

View File

@ -1,11 +1,11 @@
package com.pms.base.entity;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import com.pms.common.entity.BaseEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.math.BigDecimal;
import java.time.LocalDate;
/**
@ -49,11 +49,13 @@ public class Contract extends BaseEntity {
/** 签订日期 */
private LocalDate signDate;
/** 合同金额 */
private BigDecimal amount;
/** 合同金额(分) */
@TableField("amount")
private Long amountFen;
/** 押金 */
private BigDecimal deposit;
/** 押金(分) */
@TableField("deposit")
private Long depositFen;
/** 付款周期,如月付 */
private String paymentCycle;

View File

@ -1,11 +1,11 @@
package com.pms.base.entity;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import com.pms.common.entity.BaseEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.math.BigDecimal;
import java.time.LocalDate;
/**
@ -61,8 +61,9 @@ public class Device extends BaseEntity {
/** 供应商 */
private String supplier;
/** 采购价格 */
private BigDecimal price;
/** 采购价格(分) */
@TableField("price")
private Long priceFen;
/** 设备二维码URL */
private String qrCode;

View File

@ -1,11 +1,11 @@
package com.pms.base.entity;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import com.pms.common.entity.BaseEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.math.BigDecimal;
import java.time.LocalDate;
/**
@ -31,8 +31,9 @@ public class DeviceMaintenance extends BaseEntity {
/** 处理人 */
private String handler;
/** 维保费用 */
private BigDecimal cost;
/** 维保费用(分) */
@TableField("cost")
private Long costFen;
/** 维保结果描述 */
private String result;

View File

@ -25,12 +25,24 @@ public class Enterprise extends BaseEntity {
/** 联系人 */
private String contactPerson;
/** 联系电话 */
/** 联系电话AES-256-GCM加密存储 */
private String contactPhone;
/** 邮箱 */
/** 联系电话哈希 */
private String contactPhoneHash;
/** 联系电话后4位哈希 */
private String contactPhoneLast4Hash;
/** 邮箱AES-256-GCM加密存储 */
private String email;
/** 邮箱哈希 */
private String emailHash;
/** 邮箱后4位哈希 */
private String emailLast4Hash;
/** 企业地址 */
private String address;

View File

@ -1,5 +1,6 @@
package com.pms.base.entity;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.annotation.Version;
import com.pms.common.entity.BaseEntity;
@ -41,11 +42,13 @@ public class LeaseContract extends BaseEntity {
/** 租赁结束日期 */
private LocalDate leaseEnd;
/** 月租金 */
private BigDecimal rentAmount;
/** 月租金(分) */
@TableField("rent_amount")
private Long rentAmountFen;
/** 押金 */
private BigDecimal deposit;
/** 押金(分) */
@TableField("deposit")
private Long depositFen;
/** 状态0草稿 1生效 2到期 3退租 4续签 */
private Integer status;

View File

@ -32,21 +32,36 @@ public class Owner extends BaseEntity {
/** 证件号码哈希(用于查询匹配) */
private String idNoHash;
/** 手机号 */
/** 手机号AES-256-GCM加密存储 */
private String phone;
/** 邮箱 */
/** 手机号哈希(用于查询匹配) */
private String phoneHash;
/** 手机号后4位哈希用于尾号查询 */
private String phoneLast4Hash;
/** 邮箱AES-256-GCM加密存储 */
private String email;
/** 邮箱哈希 */
private String emailHash;
/** 邮箱后4位哈希 */
private String emailLast4Hash;
/** 联系地址 */
private String address;
/** 紧急联系人 */
private String emergencyContact;
/** 紧急联系电话 */
/** 紧急联系电话AES-256-GCM加密存储 */
private String emergencyPhone;
/** 紧急联系电话哈希 */
private String emergencyPhoneHash;
/** 状态0禁用 1启用 */
private Integer status;

View File

@ -1,12 +1,11 @@
package com.pms.base.entity;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import com.pms.common.entity.BaseEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.math.BigDecimal;
/**
* 公共收益实体
*/
@ -18,8 +17,9 @@ public class PublicRevenue extends BaseEntity {
/** 收益类型,如广告位/场地租赁 */
private String revenueType;
/** 收益金额 */
private BigDecimal amount;
/** 收益金额(分) */
@TableField("amount")
private Long amountFen;
/** 所属周期,如 2025-06 */
private String period;

View File

@ -1,12 +1,12 @@
package com.pms.base.entity;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.annotation.Version;
import com.pms.common.entity.BaseEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.math.BigDecimal;
import java.time.LocalDate;
/**
@ -40,8 +40,9 @@ public class Renovation extends BaseEntity {
/** 装修结束日期 */
private LocalDate endDate;
/** 装修押金 */
private BigDecimal depositAmount;
/** 装修押金(分) */
@TableField("deposit_amount")
private Long depositAmountFen;
/** 施工单位 */
private String constructionCompany;

View File

@ -29,12 +29,24 @@ public class Tenant extends BaseEntity {
/** 证件号码哈希 */
private String idNoHash;
/** 手机号 */
/** 手机号AES-256-GCM加密存储 */
private String phone;
/** 邮箱 */
/** 手机号哈希 */
private String phoneHash;
/** 手机号后4位哈希 */
private String phoneLast4Hash;
/** 邮箱AES-256-GCM加密存储 */
private String email;
/** 邮箱哈希 */
private String emailHash;
/** 邮箱后4位哈希 */
private String emailLast4Hash;
/** 联系地址 */
private String address;

View File

@ -1,5 +1,6 @@
package com.pms.base.entity;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.annotation.Version;
import com.pms.common.entity.BaseEntity;
@ -32,8 +33,9 @@ public class WorkshopLease extends BaseEntity {
/** 租期结束 */
private LocalDate leaseEnd;
/** 租金金额 */
private BigDecimal rentAmount;
/** 租金金额(分) */
@TableField("rent_amount")
private Long rentAmountFen;
/** 状态0草稿 1生效 2到期 3终止 */
private Integer status;

View File

@ -1,6 +1,7 @@
package com.pms.base.feign;
import com.pms.common.dto.internal.InternalServiceDTOs.UserDTO;
import com.pms.common.exception.ServiceCallException;
import com.pms.common.response.Result;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cloud.openfeign.FallbackFactory;
@ -13,6 +14,7 @@ import org.springframework.web.bind.annotation.PathVariable;
* auth-service 用户接口 Feign 客户端
* <p>
* base-service 获取操作人上下文信息
* 降级策略 ServiceCallException 阻止静默返回 null 导致后续 NPE
*/
@FeignClient(name = "auth-service", contextId = "baseAuthClient",
fallbackFactory = AuthClient.AuthClientFallbackFactory.class)
@ -25,7 +27,7 @@ public interface AuthClient {
Result<UserDTO> getUser(@PathVariable("userId") Long userId);
/**
* 降级工厂查询类返回空结果
* 降级工厂auth-service 不可用时抛异常不静默返回 null
*/
@Slf4j
@Component
@ -35,8 +37,7 @@ public interface AuthClient {
public AuthClient create(Throwable cause) {
log.error("auth-service 用户接口调用失败,触发降级", cause);
return userId -> {
log.warn("getUser 降级userId={}", userId);
return Result.success(null);
throw new ServiceCallException("认证服务暂不可用,无法获取用户信息", cause);
};
}
}

View File

@ -2,7 +2,6 @@ package com.pms.base.service;
import com.pms.base.entity.CamCharge;
import java.math.BigDecimal;
import java.util.List;
/**
@ -19,7 +18,7 @@ public interface CamChargeService {
* @param chargePeriod 分摊周期 2024-01
* @return 生成的分摊明细列表
*/
List<CamCharge> allocateCamCharge(Long projectId, BigDecimal totalAmount, String chargePeriod);
List<CamCharge> allocateCamCharge(Long projectId, Long totalAmountFen, String chargePeriod);
/**
* 查询项目某周期的分摊明细

View File

@ -3,8 +3,6 @@ package com.pms.base.service;
import com.pms.base.entity.PublicRevenue;
import com.pms.common.response.PageResult;
import java.math.BigDecimal;
/**
* 公共收益服务接口
* 提供 CRUD + 月度汇总
@ -38,7 +36,7 @@ public interface PublicRevenueService {
* @param period 周期 2025-06
* @return 该周期有效收益总额
*/
BigDecimal monthlySummary(Long projectId, String period);
Long monthlySummary(Long projectId, String period);
/**
* 删除公共收益逻辑删除

View File

@ -40,11 +40,11 @@ public class CamChargeServiceImpl implements CamChargeService {
@Override
@Transactional(rollbackFor = Exception.class)
public List<CamCharge> allocateCamCharge(Long projectId, BigDecimal totalAmount, String chargePeriod) {
public List<CamCharge> allocateCamCharge(Long projectId, Long totalAmountFen, String chargePeriod) {
if (projectId == null) {
throw new BusinessException(ErrorCode.PARAM_INVALID, "项目ID不能为空");
}
if (totalAmount == null || totalAmount.compareTo(BigDecimal.ZERO) <= 0) {
if (totalAmountFen == null || totalAmountFen <= 0) {
throw new BusinessException(ErrorCode.PARAM_INVALID, "分摊总额必须大于0");
}
if (chargePeriod == null || chargePeriod.isBlank()) {
@ -84,14 +84,15 @@ public class CamChargeServiceImpl implements CamChargeService {
}
BigDecimal area = contract.getLeaseArea() == null ? BigDecimal.ZERO : contract.getLeaseArea();
BigDecimal ratio = area.divide(totalArea, SCALE, RoundingMode.HALF_UP);
BigDecimal allocated = totalAmount.multiply(ratio).setScale(2, RoundingMode.HALF_UP);
long allocated = BigDecimal.valueOf(totalAmountFen).multiply(ratio)
.setScale(0, RoundingMode.HALF_UP).longValueExact();
CamCharge charge = new CamCharge();
charge.setProjectId(projectId);
charge.setContractId(contract.getId());
charge.setChargePeriod(chargePeriod);
charge.setTotalAmount(totalAmount);
charge.setAllocatedAmount(allocated);
charge.setTotalAmountFen(totalAmountFen);
charge.setAllocatedAmountFen(allocated);
charge.setRatio(ratio);
charge.setLeaseArea(area);
charge.setStatus(STATUS_PENDING);
@ -103,7 +104,7 @@ public class CamChargeServiceImpl implements CamChargeService {
result.add(charge);
}
log.info("CAM 分摊完成: projectId={}, period={}, total={}, bills={}, skipped={}",
projectId, chargePeriod, totalAmount, result.size(), existingContractIds.size());
projectId, chargePeriod, totalAmountFen, result.size(), existingContractIds.size());
return result;
}

View File

@ -87,8 +87,8 @@ public class ContractServiceImpl implements ContractService {
contract.setStartDate(request.getStartDate());
contract.setEndDate(request.getEndDate());
contract.setSignDate(request.getSignDate());
contract.setAmount(request.getAmount());
contract.setDeposit(request.getDeposit());
contract.setAmountFen(request.getAmountFen());
contract.setDepositFen(request.getDepositFen());
contract.setPaymentCycle(request.getPaymentCycle());
contract.setAttachment(request.getAttachment());
contract.setStatus(request.getStatus() != null ? request.getStatus() : 0);
@ -130,8 +130,8 @@ public class ContractServiceImpl implements ContractService {
update.setStartDate(request.getStartDate());
update.setEndDate(request.getEndDate());
update.setSignDate(request.getSignDate());
update.setAmount(request.getAmount());
update.setDeposit(request.getDeposit());
update.setAmountFen(request.getAmountFen());
update.setDepositFen(request.getDepositFen());
update.setPaymentCycle(request.getPaymentCycle());
update.setAttachment(request.getAttachment());
if (request.getStatus() != null) {
@ -193,8 +193,8 @@ public class ContractServiceImpl implements ContractService {
newContract.setStartDate(existing.getEndDate());
newContract.setEndDate(request.getNewEndDate());
newContract.setSignDate(request.getSignDate());
newContract.setAmount(request.getAmount() != null ? request.getAmount() : existing.getAmount());
newContract.setDeposit(existing.getDeposit());
newContract.setAmountFen(request.getAmountFen() != null ? request.getAmountFen() : existing.getAmountFen());
newContract.setDepositFen(existing.getDepositFen());
newContract.setPaymentCycle(existing.getPaymentCycle());
newContract.setAttachment(existing.getAttachment());
newContract.setStatus(1);

View File

@ -86,6 +86,7 @@ public class ContractTypeServiceImpl implements ContractTypeService {
}
@Override
@Transactional(rollbackFor = Exception.class)
public void delete(Long id) {
ContractType existing = contractTypeMapper.selectById(id);
if (existing == null) {

View File

@ -95,6 +95,7 @@ public class DeviceCategoryServiceImpl implements DeviceCategoryService {
}
@Override
@Transactional(rollbackFor = Exception.class)
public void delete(Long id) {
DeviceCategory existing = deviceCategoryMapper.selectById(id);
if (existing == null) {

View File

@ -63,7 +63,7 @@ public class DeviceMaintenanceServiceImpl implements DeviceMaintenanceService {
dto.setMaintenanceDate(maintenance.getMaintenanceDate());
dto.setPlanDate(maintenance.getPlanDate());
dto.setHandler(maintenance.getHandler());
dto.setCost(maintenance.getCost());
dto.setCostFen(maintenance.getCostFen());
dto.setResult(maintenance.getResult());
dto.setImages(maintenance.getImages());
dto.setStatus(maintenance.getStatus());
@ -101,7 +101,7 @@ public class DeviceMaintenanceServiceImpl implements DeviceMaintenanceService {
maintenance.setMaintenanceDate(request.getMaintenanceDate());
maintenance.setPlanDate(request.getPlanDate());
maintenance.setHandler(request.getHandler());
maintenance.setCost(request.getCost());
maintenance.setCostFen(request.getCostFen());
maintenance.setResult(request.getResult());
// 图片列表转逗号分隔字符串
if (request.getImages() != null && !request.getImages().isEmpty()) {

View File

@ -84,7 +84,7 @@ public class DeviceServiceImpl implements DeviceService {
device.setInstallDate(request.getInstallDate());
device.setWarrantyUntil(request.getWarrantyUntil());
device.setSupplier(request.getSupplier());
device.setPrice(request.getPrice());
device.setPriceFen(request.getPriceFen());
device.setStatus(request.getStatus() != null ? request.getStatus() : CommonConstants.STATUS_ENABLED);
device.setRemark(request.getRemark());
@ -125,7 +125,7 @@ public class DeviceServiceImpl implements DeviceService {
update.setInstallDate(request.getInstallDate());
update.setWarrantyUntil(request.getWarrantyUntil());
update.setSupplier(request.getSupplier());
update.setPrice(request.getPrice());
update.setPriceFen(request.getPriceFen());
if (request.getStatus() != null) {
update.setStatus(request.getStatus());
}

View File

@ -13,6 +13,7 @@ import com.pms.common.exception.BusinessException;
import com.pms.common.exception.ErrorCode;
import com.pms.common.response.PageResult;
import com.pms.common.security.UserContext;
import com.pms.common.util.CryptoUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
@ -27,6 +28,7 @@ import org.springframework.transaction.annotation.Transactional;
public class EnterpriseServiceImpl implements EnterpriseService {
private final EnterpriseMapper enterpriseMapper;
private final CryptoUtil cryptoUtil;
private final LifecycleWriteGuard lifecycleWriteGuard;
@Override
@ -65,6 +67,23 @@ public class EnterpriseServiceImpl implements EnterpriseService {
dto.setRemark(enterprise.getRemark());
dto.setCreatedAt(enterprise.getCreatedAt());
// 解密联系电话
if (dto.getContactPhone() != null && !dto.getContactPhone().isEmpty()) {
try {
dto.setContactPhone(cryptoUtil.decrypt(dto.getContactPhone()));
} catch (Exception e) {
log.warn("解密联系电话失败: enterpriseId={}", id);
}
}
// 解密邮箱
if (dto.getEmail() != null && !dto.getEmail().isEmpty()) {
try {
dto.setEmail(cryptoUtil.decrypt(dto.getEmail()));
} catch (Exception e) {
log.warn("解密邮箱失败: enterpriseId={}", id);
}
}
return dto;
}
@ -80,8 +99,18 @@ public class EnterpriseServiceImpl implements EnterpriseService {
enterprise.setCreditCode(request.getCreditCode());
enterprise.setLegalPerson(request.getLegalPerson());
enterprise.setContactPerson(request.getContactPerson());
enterprise.setContactPhone(request.getContactPhone());
enterprise.setEmail(request.getEmail());
// 加密联系电话
if (request.getContactPhone() != null && !request.getContactPhone().isEmpty()) {
enterprise.setContactPhone(cryptoUtil.encrypt(request.getContactPhone()));
enterprise.setContactPhoneHash(cryptoUtil.hash(request.getContactPhone()));
enterprise.setContactPhoneLast4Hash(cryptoUtil.hash(request.getContactPhone().substring(Math.max(0, request.getContactPhone().length() - 4))));
}
// 加密邮箱
if (request.getEmail() != null && !request.getEmail().isEmpty()) {
enterprise.setEmail(cryptoUtil.encrypt(request.getEmail()));
enterprise.setEmailHash(cryptoUtil.hash(request.getEmail()));
enterprise.setEmailLast4Hash(cryptoUtil.hash(request.getEmail().substring(Math.max(0, request.getEmail().length() - 4))));
}
enterprise.setAddress(request.getAddress());
enterprise.setBusinessScope(request.getBusinessScope());
enterprise.setRoomIds(request.getRoomIds());
@ -114,8 +143,18 @@ public class EnterpriseServiceImpl implements EnterpriseService {
update.setCreditCode(request.getCreditCode());
update.setLegalPerson(request.getLegalPerson());
update.setContactPerson(request.getContactPerson());
update.setContactPhone(request.getContactPhone());
update.setEmail(request.getEmail());
// 加密联系电话
if (request.getContactPhone() != null && !request.getContactPhone().isEmpty()) {
update.setContactPhone(cryptoUtil.encrypt(request.getContactPhone()));
update.setContactPhoneHash(cryptoUtil.hash(request.getContactPhone()));
update.setContactPhoneLast4Hash(cryptoUtil.hash(request.getContactPhone().substring(Math.max(0, request.getContactPhone().length() - 4))));
}
// 加密邮箱
if (request.getEmail() != null && !request.getEmail().isEmpty()) {
update.setEmail(cryptoUtil.encrypt(request.getEmail()));
update.setEmailHash(cryptoUtil.hash(request.getEmail()));
update.setEmailLast4Hash(cryptoUtil.hash(request.getEmail().substring(Math.max(0, request.getEmail().length() - 4))));
}
update.setAddress(request.getAddress());
update.setBusinessScope(request.getBusinessScope());
update.setRoomIds(request.getRoomIds());

View File

@ -68,6 +68,30 @@ public class OwnerServiceImpl implements OwnerService {
log.warn("解密身份证号失败: ownerId={}", id);
}
}
// 解密手机号
if (dto.getPhone() != null && !dto.getPhone().isEmpty()) {
try {
dto.setPhone(cryptoUtil.decrypt(dto.getPhone()));
} catch (Exception e) {
log.warn("解密手机号失败: ownerId={}", id);
}
}
// 解密邮箱
if (dto.getEmail() != null && !dto.getEmail().isEmpty()) {
try {
dto.setEmail(cryptoUtil.decrypt(dto.getEmail()));
} catch (Exception e) {
log.warn("解密邮箱失败: ownerId={}", id);
}
}
// 解密紧急联系电话
if (dto.getEmergencyPhone() != null && !dto.getEmergencyPhone().isEmpty()) {
try {
dto.setEmergencyPhone(cryptoUtil.decrypt(dto.getEmergencyPhone()));
} catch (Exception e) {
log.warn("解密紧急联系电话失败: ownerId={}", id);
}
}
return dto;
}
@ -103,11 +127,25 @@ public class OwnerServiceImpl implements OwnerService {
owner.setIdNo(cryptoUtil.encrypt(request.getIdNo()));
owner.setIdNoHash(cryptoUtil.hash(request.getIdNo()));
}
owner.setPhone(request.getPhone());
owner.setEmail(request.getEmail());
// 加密手机号
if (request.getPhone() != null && !request.getPhone().isEmpty()) {
owner.setPhone(cryptoUtil.encrypt(request.getPhone()));
owner.setPhoneHash(cryptoUtil.hash(request.getPhone()));
owner.setPhoneLast4Hash(cryptoUtil.hash(request.getPhone().substring(Math.max(0, request.getPhone().length() - 4))));
}
// 加密邮箱
if (request.getEmail() != null && !request.getEmail().isEmpty()) {
owner.setEmail(cryptoUtil.encrypt(request.getEmail()));
owner.setEmailHash(cryptoUtil.hash(request.getEmail()));
owner.setEmailLast4Hash(cryptoUtil.hash(request.getEmail().substring(Math.max(0, request.getEmail().length() - 4))));
}
owner.setAddress(request.getAddress());
owner.setEmergencyContact(request.getEmergencyContact());
owner.setEmergencyPhone(request.getEmergencyPhone());
// 加密紧急联系电话
if (request.getEmergencyPhone() != null && !request.getEmergencyPhone().isEmpty()) {
owner.setEmergencyPhone(cryptoUtil.encrypt(request.getEmergencyPhone()));
owner.setEmergencyPhoneHash(cryptoUtil.hash(request.getEmergencyPhone()));
}
owner.setStatus(request.getStatus() != null ? request.getStatus() : CommonConstants.STATUS_ENABLED);
owner.setRemark(request.getRemark());
@ -142,11 +180,25 @@ public class OwnerServiceImpl implements OwnerService {
update.setIdNo(cryptoUtil.encrypt(request.getIdNo()));
update.setIdNoHash(cryptoUtil.hash(request.getIdNo()));
}
update.setPhone(request.getPhone());
update.setEmail(request.getEmail());
// 加密手机号
if (request.getPhone() != null && !request.getPhone().isEmpty()) {
update.setPhone(cryptoUtil.encrypt(request.getPhone()));
update.setPhoneHash(cryptoUtil.hash(request.getPhone()));
update.setPhoneLast4Hash(cryptoUtil.hash(request.getPhone().substring(Math.max(0, request.getPhone().length() - 4))));
}
// 加密邮箱
if (request.getEmail() != null && !request.getEmail().isEmpty()) {
update.setEmail(cryptoUtil.encrypt(request.getEmail()));
update.setEmailHash(cryptoUtil.hash(request.getEmail()));
update.setEmailLast4Hash(cryptoUtil.hash(request.getEmail().substring(Math.max(0, request.getEmail().length() - 4))));
}
update.setAddress(request.getAddress());
update.setEmergencyContact(request.getEmergencyContact());
update.setEmergencyPhone(request.getEmergencyPhone());
// 加密紧急联系电话
if (request.getEmergencyPhone() != null && !request.getEmergencyPhone().isEmpty()) {
update.setEmergencyPhone(cryptoUtil.encrypt(request.getEmergencyPhone()));
update.setEmergencyPhoneHash(cryptoUtil.hash(request.getEmergencyPhone()));
}
if (request.getStatus() != null) {
update.setStatus(request.getStatus());
}

View File

@ -87,7 +87,6 @@ public class ProjectServiceImpl implements ProjectService {
project.setLogo(request.getLogo());
project.setStatus(request.getStatus() != null ? request.getStatus() : CommonConstants.STATUS_ENABLED);
project.setSort(request.getSort() != null ? request.getSort() : 0);
project.setProjectId(null);
// R24: lifecycle_stage 服务端权威新建项目从立项开始
project.setLifecycleStage(LifecycleStage.INITIATION);
@ -167,6 +166,7 @@ public class ProjectServiceImpl implements ProjectService {
}
@Override
@Transactional(rollbackFor = Exception.class)
public void delete(Long id) {
Project existing = projectMapper.selectById(id);
if (existing == null) {

View File

@ -19,7 +19,6 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.math.BigDecimal;
import java.util.List;
/**
@ -72,8 +71,8 @@ public class PublicRevenueServiceImpl implements PublicRevenueService {
if (!StringUtils.hasText(revenue.getPeriod())) {
throw new BusinessException(ErrorCode.PARAM_INVALID, "周期不能为空");
}
if (revenue.getAmount() == null) {
revenue.setAmount(BigDecimal.ZERO);
if (revenue.getAmountFen() == null) {
revenue.setAmountFen(0L);
}
if (revenue.getReceivedAt() == null) {
revenue.setReceivedAt(System.currentTimeMillis());
@ -88,7 +87,7 @@ public class PublicRevenueServiceImpl implements PublicRevenueService {
publicRevenueMapper.insert(revenue);
log.info("录入公共收益成功: id={}, projectId={}, type={}, amount={}",
revenue.getId(), revenue.getProjectId(), revenue.getRevenueType(), revenue.getAmount());
revenue.getId(), revenue.getProjectId(), revenue.getRevenueType(), revenue.getAmountFen());
return revenue.getId();
}
@ -105,7 +104,7 @@ public class PublicRevenueServiceImpl implements PublicRevenueService {
PublicRevenue update = new PublicRevenue();
update.setId(id);
update.setRevenueType(revenue.getRevenueType());
update.setAmount(revenue.getAmount());
update.setAmountFen(revenue.getAmountFen());
update.setPeriod(revenue.getPeriod());
update.setReceivedAt(revenue.getReceivedAt());
update.setDescription(revenue.getDescription());
@ -120,9 +119,9 @@ public class PublicRevenueServiceImpl implements PublicRevenueService {
}
@Override
public BigDecimal monthlySummary(Long projectId, String period) {
public Long monthlySummary(Long projectId, String period) {
if (projectId == null || !StringUtils.hasText(period)) {
return BigDecimal.ZERO;
return 0L;
}
LambdaQueryWrapper<PublicRevenue> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(PublicRevenue::getProjectId, projectId)
@ -130,8 +129,8 @@ public class PublicRevenueServiceImpl implements PublicRevenueService {
.eq(PublicRevenue::getStatus, CommonConstants.STATUS_ENABLED);
List<PublicRevenue> list = publicRevenueMapper.selectList(wrapper);
return list.stream()
.map(PublicRevenue::getAmount)
.reduce(BigDecimal.ZERO, BigDecimal::add);
.map(PublicRevenue::getAmountFen)
.reduce(0L, Long::sum);
}
@Override

View File

@ -89,8 +89,8 @@ public class RenovationServiceImpl implements RenovationService {
// 创建装修记录状态=0待审批
long now = System.currentTimeMillis();
renovation.setStatus(STATUS_PENDING);
if (renovation.getDepositAmount() == null) {
renovation.setDepositAmount(java.math.BigDecimal.ZERO);
if (renovation.getDepositAmountFen() == null) {
renovation.setDepositAmountFen(0L);
}
renovation.setCreatedAt(now);
renovation.setUpdatedAt(now);

View File

@ -60,8 +60,18 @@ public class TenantServiceImpl implements TenantService {
tenant.setIdNo(cryptoUtil.encrypt(request.getIdNo()));
tenant.setIdNoHash(cryptoUtil.hash(request.getIdNo()));
}
tenant.setPhone(request.getPhone());
tenant.setEmail(request.getEmail());
// 加密手机号
if (request.getPhone() != null && !request.getPhone().isEmpty()) {
tenant.setPhone(cryptoUtil.encrypt(request.getPhone()));
tenant.setPhoneHash(cryptoUtil.hash(request.getPhone()));
tenant.setPhoneLast4Hash(cryptoUtil.hash(request.getPhone().substring(Math.max(0, request.getPhone().length() - 4))));
}
// 加密邮箱
if (request.getEmail() != null && !request.getEmail().isEmpty()) {
tenant.setEmail(cryptoUtil.encrypt(request.getEmail()));
tenant.setEmailHash(cryptoUtil.hash(request.getEmail()));
tenant.setEmailLast4Hash(cryptoUtil.hash(request.getEmail().substring(Math.max(0, request.getEmail().length() - 4))));
}
tenant.setAddress(request.getAddress());
tenant.setStatus(request.getStatus() != null ? request.getStatus() : CommonConstants.STATUS_ENABLED);
tenant.setRemark(request.getRemark());
@ -95,8 +105,18 @@ public class TenantServiceImpl implements TenantService {
update.setIdNo(cryptoUtil.encrypt(request.getIdNo()));
update.setIdNoHash(cryptoUtil.hash(request.getIdNo()));
}
update.setPhone(request.getPhone());
update.setEmail(request.getEmail());
// 加密手机号
if (request.getPhone() != null && !request.getPhone().isEmpty()) {
update.setPhone(cryptoUtil.encrypt(request.getPhone()));
update.setPhoneHash(cryptoUtil.hash(request.getPhone()));
update.setPhoneLast4Hash(cryptoUtil.hash(request.getPhone().substring(Math.max(0, request.getPhone().length() - 4))));
}
// 加密邮箱
if (request.getEmail() != null && !request.getEmail().isEmpty()) {
update.setEmail(cryptoUtil.encrypt(request.getEmail()));
update.setEmailHash(cryptoUtil.hash(request.getEmail()));
update.setEmailLast4Hash(cryptoUtil.hash(request.getEmail().substring(Math.max(0, request.getEmail().length() - 4))));
}
update.setAddress(request.getAddress());
if (request.getStatus() != null) {
update.setStatus(request.getStatus());

View File

@ -80,7 +80,7 @@ public class WorkshopLeaseServiceImpl implements WorkshopLeaseService {
update.setTenantEnterprise(entity.getTenantEnterprise());
update.setLeaseStart(entity.getLeaseStart());
update.setLeaseEnd(entity.getLeaseEnd());
update.setRentAmount(entity.getRentAmount());
update.setRentAmountFen(entity.getRentAmountFen());
if (entity.getStatus() != null) {
update.setStatus(entity.getStatus());
}
@ -111,7 +111,7 @@ public class WorkshopLeaseServiceImpl implements WorkshopLeaseService {
// 首期最小实现基于租赁合同生成账单编号预留与 pms-charge 对接点
String billNo = "BILL-" + lease.getId() + "-" + System.currentTimeMillis();
log.info("生成租金账单: leaseId={}, tenant={}, rentAmount={}, billNo={}",
leaseId, lease.getTenantEnterprise(), lease.getRentAmount(), billNo);
leaseId, lease.getTenantEnterprise(), lease.getRentAmountFen(), billNo);
return billNo;
}
}

View File

@ -1,71 +1,167 @@
package com.pms.base.util;
import cn.hutool.crypto.SecureUtil;
import cn.hutool.crypto.symmetric.AES;
import cn.hutool.crypto.symmetric.SymmetricCrypto;
import com.pms.common.exception.BusinessException;
import com.pms.common.exception.ErrorCode;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import javax.crypto.Cipher;
import javax.crypto.Mac;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.util.Arrays;
import java.util.Base64;
/**
* 敏感数据加密工具类
* 使用 AES-256-GCM 对身份证号等敏感字段进行加密/解密
* 查询时使用 SHA-256 哈希进行匹配
* 敏感数据加密工具类AES-256-GCM
*
* <p>使用 JDK 原生 {@code Cipher.getInstance("AES/GCM/NoPadding")} 实现 GCM 模式
* Hutool 5.8.27 Mode 枚举不支持 GCM POC 验证
*
* <p>安全特性
* <ul>
* <li>密钥从 Nacos 配置中心加载{@code app.encrypt.aes-key}无默认值启动时校验</li>
* <li>IV 使用 {@link SecureRandom}CSPRNG每记录独立生成与密文一同存储</li>
* <li>hash 使用 HMAC-SHA256抗彩虹表需要 server secret</li>
* <li>加解密异常抛出 {@link BusinessException}不吞掉</li>
* <li>SHA-256 派生 32 字节密钥替代旧的 0 填充 padKey</li>
* </ul>
*
* <p>输出格式{@code Base64(IV || ciphertext || authTag)}其中 IV 12 字节authTag 16 字节
*
* <p>注意业务代码OwnerServiceImpl/TenantServiceImpl实际使用 pms-common CryptoUtil
* 此类为 pms-base 本地实现供计划 U1 要求的独立加密工具HMAC-SHA256 hash CryptoUtil
* SHA-256 hash 不兼容不可混用查询
*/
@Component
public class AesEncryptUtil {
private final SymmetricCrypto aes;
private static final String GCM_TRANSFORMATION = "AES/GCM/NoPadding";
private static final int GCM_TAG_LENGTH_BITS = 128;
private static final int GCM_IV_LENGTH_BYTES = 12;
private static final String HMAC_ALGORITHM = "HmacSHA256";
public AesEncryptUtil(@Value("${app.encrypt.aes-key:EtherPMS2024SecureKey!!}") String key) {
// 使用 AES-256-ECBHutool默认生产环境建议使用 GCM 模式
byte[] keyBytes = padKey(key.getBytes(StandardCharsets.UTF_8));
this.aes = new AES(keyBytes);
private final byte[] aesKeyBytes;
private final byte[] hmacKeyBytes;
private final SecureRandom secureRandom = new SecureRandom();
/**
* @param aesKey AES 密钥 Nacos 加载无默认值未配置时启动失败
*/
public AesEncryptUtil(@Value("${app.encrypt.aes-key}") String aesKey) {
if (aesKey == null || aesKey.isBlank()) {
throw new IllegalStateException("AES密钥未配置请在 Nacos 设置 app.encrypt.aes-key");
}
// SHA-256 派生固定 32 字节 AES 密钥替代旧 padKey 0 填充
this.aesKeyBytes = sha256(aesKey);
// HMAC 密钥从 AES 密钥派生避免引入额外配置项
this.hmacKeyBytes = sha256(aesKey + ":hmac");
}
/**
* 加密敏感数据
* AES-256-GCM 加密
*
* @param plainText 明文
* @return Base64 编码的密文IV + ciphertext + authTag输入 null/空时返回 null
* @throws BusinessException 加密失败时抛出
*/
public String encrypt(String plainText) {
if (plainText == null || plainText.isEmpty()) {
return null;
}
return aes.encryptHex(plainText);
try {
byte[] iv = new byte[GCM_IV_LENGTH_BYTES];
secureRandom.nextBytes(iv);
Cipher cipher = Cipher.getInstance(GCM_TRANSFORMATION);
cipher.init(Cipher.ENCRYPT_MODE,
new SecretKeySpec(aesKeyBytes, "AES"),
new GCMParameterSpec(GCM_TAG_LENGTH_BITS, iv));
byte[] cipherText = cipher.doFinal(plainText.getBytes(StandardCharsets.UTF_8));
// IV 前缀拼接密文 authTag
byte[] combined = new byte[GCM_IV_LENGTH_BYTES + cipherText.length];
System.arraycopy(iv, 0, combined, 0, GCM_IV_LENGTH_BYTES);
System.arraycopy(cipherText, 0, combined, GCM_IV_LENGTH_BYTES, cipherText.length);
return Base64.getEncoder().encodeToString(combined);
} catch (Exception e) {
throw new BusinessException(ErrorCode.OPERATION_FAILED, "AES加密失败: " + e.getMessage());
}
}
/**
* 解密敏感数据
* AES-256-GCM 解密
*
* @param cipherText Base64 编码的密文IV + ciphertext + authTag
* @return 明文输入 null/空时返回 null
* @throws BusinessException 解密失败时抛出不吞异常
*/
public String decrypt(String cipherText) {
if (cipherText == null || cipherText.isEmpty()) {
return null;
}
try {
return aes.decryptStr(cipherText);
byte[] combined = Base64.getDecoder().decode(cipherText);
if (combined.length < GCM_IV_LENGTH_BYTES) {
throw new IllegalArgumentException("密文长度不足,缺少 IV");
}
byte[] iv = Arrays.copyOfRange(combined, 0, GCM_IV_LENGTH_BYTES);
byte[] encrypted = Arrays.copyOfRange(combined, GCM_IV_LENGTH_BYTES, combined.length);
Cipher cipher = Cipher.getInstance(GCM_TRANSFORMATION);
cipher.init(Cipher.DECRYPT_MODE,
new SecretKeySpec(aesKeyBytes, "AES"),
new GCMParameterSpec(GCM_TAG_LENGTH_BITS, iv));
byte[] decrypted = cipher.doFinal(encrypted);
return new String(decrypted, StandardCharsets.UTF_8);
} catch (Exception e) {
return null;
throw new BusinessException(ErrorCode.OPERATION_FAILED, "AES解密失败: " + e.getMessage());
}
}
/**
* 生成哈希值用于查询匹配
* HMAC-SHA256 哈希抗彩虹表用于查询匹配
*
* <p> CryptoUtil.hash()SHA-256不兼容不可混用查询
*
* @param plainText 明文
* @return 64 位十六进制哈希值输入 null/空时返回 null
* @throws BusinessException 哈希失败时抛出
*/
public String hash(String plainText) {
if (plainText == null || plainText.isEmpty()) {
return null;
}
return SecureUtil.sha256(plainText);
try {
Mac mac = Mac.getInstance(HMAC_ALGORITHM);
mac.init(new SecretKeySpec(hmacKeyBytes, HMAC_ALGORITHM));
byte[] hashBytes = mac.doFinal(plainText.getBytes(StandardCharsets.UTF_8));
return bytesToHex(hashBytes);
} catch (Exception e) {
throw new BusinessException(ErrorCode.OPERATION_FAILED, "HMAC哈希失败: " + e.getMessage());
}
}
/**
* 将密钥补齐到32字节AES-256
*/
private byte[] padKey(byte[] keyBytes) {
byte[] result = new byte[32];
for (int i = 0; i < 32; i++) {
result[i] = i < keyBytes.length ? keyBytes[i] : 0;
/** SHA-256 派生固定 32 字节密钥 */
private static byte[] sha256(String input) {
try {
return MessageDigest.getInstance("SHA-256")
.digest(input.getBytes(StandardCharsets.UTF_8));
} catch (Exception e) {
throw new IllegalStateException("SHA-256 不可用", e);
}
return result;
}
private static String bytesToHex(byte[] bytes) {
StringBuilder sb = new StringBuilder(bytes.length * 2);
for (byte b : bytes) {
sb.append(Character.forDigit((b >> 4) & 0xF, 16));
sb.append(Character.forDigit(b & 0xF, 16));
}
return sb.toString();
}
}

View File

@ -0,0 +1,145 @@
package db.migration;
import lombok.extern.slf4j.Slf4j;
import org.flywaydb.core.api.migration.BaseJavaMigration;
import org.flywaydb.core.api.migration.Context;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.Statement;
import java.util.Arrays;
import java.util.Base64;
import javax.crypto.Cipher;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
/**
* Flyway V12: AES ECBGCM 迁移 + idNoHash 填充
*
* <p>背景历史 AesEncryptUtil 使用 ECB 模式且未填充 idNoHash本迁移
* <ol>
* <li>扫描 t_owner / t_tenant id_no IS NOT NULL id_no_hash IS NULL 的记录</li>
* <li>若存在 null idNoHash读取 AES_KEY 环境变量解密 id_noGCM计算 SHA-256 hash 填充</li>
* <li>若不存在 null idNoHash迁移为 no-op</li>
* </ol>
*
* <p>注意业务代码实际使用 pms-common CryptoUtil已为 GCM 模式AesEncryptUtil 为死代码
* 本迁移作为防御性数据完整性检查处理可能存在的历史 null idNoHash 数据
*
* <p>幂等性仅更新 id_no_hash IS NULL 的记录重复执行无副作用
*/
@Slf4j
public class V12__MigrateAesEcbToGcm extends BaseJavaMigration {
private static final int GCM_IV_LENGTH = 12;
private static final int GCM_TAG_LENGTH_BITS = 128;
@Override
public void migrate(Context context) throws Exception {
Connection conn = context.getConnection();
int ownerNullHash = countNullHash(conn, "t_owner");
int tenantNullHash = countNullHash(conn, "t_tenant");
int totalNullHash = ownerNullHash + tenantNullHash;
log.info("[V12] 扫描 idNoHash 为空记录: t_owner={}, t_tenant={}, 总计={}",
ownerNullHash, tenantNullHash, totalNullHash);
if (totalNullHash == 0) {
log.info("[V12] 无需迁移,所有 idNoHash 已填充");
return;
}
// 需要 AES_KEY 解密 id_no 后计算 hash
String aesKey = System.getenv("AES_KEY");
if (aesKey == null || aesKey.isBlank()) {
log.warn("[V12] AES_KEY 环境变量未设置,跳过 {} 条 null idNoHash 填充。"
+ "请手动设置 AES_KEY 后重新执行迁移,或手动填充 idNoHash。", totalNullHash);
return;
}
byte[] keyBytes = MessageDigest.getInstance("SHA-256")
.digest(aesKey.getBytes(StandardCharsets.UTF_8));
int ownerFixed = fillNullHash(conn, "t_owner", keyBytes);
int tenantFixed = fillNullHash(conn, "t_tenant", keyBytes);
log.info("[V12] idNoHash 填充完成: t_owner={} 条, t_tenant={} 条", ownerFixed, tenantFixed);
}
/** 统计 id_no 非空但 id_no_hash 为空的记录数 */
private int countNullHash(Connection conn, String table) throws Exception {
try (Statement stmt = conn.createStatement();
ResultSet rs = stmt.executeQuery(
"SELECT COUNT(*) FROM " + table + " WHERE id_no IS NOT NULL AND id_no_hash IS NULL")) {
rs.next();
return rs.getInt(1);
}
}
/** 填充 null idNoHash解密 id_no → SHA-256 hash → 更新(批量) */
private int fillNullHash(Connection conn, String table, byte[] keyBytes) throws Exception {
int fixed = 0;
int batchSize = 0;
final int FLUSH_EVERY = 500;
try (PreparedStatement select = conn.prepareStatement(
"SELECT id, id_no FROM " + table + " WHERE id_no IS NOT NULL AND id_no_hash IS NULL");
PreparedStatement update = conn.prepareStatement(
"UPDATE " + table + " SET id_no_hash = ? WHERE id = ?");
ResultSet rs = select.executeQuery()) {
while (rs.next()) {
long id = rs.getLong("id");
String cipherText = rs.getString("id_no");
try {
String plainText = decryptGcm(cipherText, keyBytes);
String hash = sha256Hex(plainText);
update.setString(1, hash);
update.setLong(2, id);
update.addBatch();
fixed++;
if (++batchSize >= FLUSH_EVERY) {
update.executeBatch();
batchSize = 0;
}
} catch (Exception e) {
log.warn("[V12] {} id={} 解密失败,跳过: {}", table, id, e.getMessage());
}
}
if (batchSize > 0) {
update.executeBatch();
}
}
return fixed;
}
/** AES-256-GCM 解密(与 CryptoUtil/AesEncryptUtil 格式一致Base64(IV + ciphertext + tag) */
private String decryptGcm(String cipherText, byte[] keyBytes) throws Exception {
byte[] combined = Base64.getDecoder().decode(cipherText);
byte[] iv = Arrays.copyOfRange(combined, 0, GCM_IV_LENGTH);
byte[] encrypted = Arrays.copyOfRange(combined, GCM_IV_LENGTH, combined.length);
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.DECRYPT_MODE,
new SecretKeySpec(keyBytes, "AES"),
new GCMParameterSpec(GCM_TAG_LENGTH_BITS, iv));
byte[] decrypted = cipher.doFinal(encrypted);
return new String(decrypted, StandardCharsets.UTF_8);
}
/** SHA-256 哈希(与 CryptoUtil.hash() 一致) */
private String sha256Hex(String input) throws Exception {
byte[] hash = MessageDigest.getInstance("SHA-256")
.digest(input.getBytes(StandardCharsets.UTF_8));
StringBuilder sb = new StringBuilder(hash.length * 2);
for (byte b : hash) {
sb.append(Character.forDigit((b >> 4) & 0xF, 16));
sb.append(Character.forDigit(b & 0xF, 16));
}
return sb.toString();
}
}

View File

@ -66,6 +66,26 @@ spring:
connect-timeout: 3000
read-timeout: 5000
# Resilience4j 熔断配置U7
resilience4j:
circuitbreaker:
configs:
default:
failure-rate-threshold: 50
slow-call-rate-threshold: 80
slow-call-duration-threshold: 2s
wait-duration-in-open-state: 30s
sliding-window-size: 20
minimum-number-of-calls: 10
permitted-number-of-calls-in-half-open-state: 10
instances:
default:
base-config: default
timelimiter:
configs:
default:
timeout-duration: 6s
mybatis-plus:
mapper-locations: classpath*:mapper/**/*.xml
type-aliases-package: com.pms.base.**.entity
@ -81,10 +101,15 @@ mybatis-plus:
cache-enabled: false
# 敏感数据加密配置AES-256-GCM
# 生产环境通过环境变量 AES_KEY 注入实际密钥
# 生产环境通过环境变量 AES_KEY 注入实际密钥(无默认值,未配置时启动失败)
crypto:
aes:
key: ${AES_KEY:EtherPMS2024AesSecretKey32Bytes!}
key: ${AES_KEY}
# pms-base 独立加密工具配置U1: AesEncryptUtil 使用)
app:
encrypt:
aes-key: ${AES_KEY}
# 内部接口鉴权配置U9
internal:

View File

@ -0,0 +1,39 @@
-- V13: BigDecimal 金额字段迁移到 Long
-- 仅迁移金额字段,非金额 BigDecimal面积/比例/经纬度/仪表读数)保持不变
-- 值转换DECIMAL(15,2) 元 → BIGINT 分(× 100四舍五入
-- 注意UPDATE 必须在 ALTER 之前执行,否则 DECIMAL→BIGINT 转换会先四舍五入丢失分位
-- t_contract: amount, deposit
UPDATE t_contract SET amount = ROUND(amount * 100, 0), deposit = ROUND(deposit * 100, 0);
ALTER TABLE t_contract MODIFY COLUMN amount BIGINT NOT NULL DEFAULT 0 COMMENT '合同金额(分)';
ALTER TABLE t_contract MODIFY COLUMN deposit BIGINT NOT NULL DEFAULT 0 COMMENT '押金(分)';
-- t_lease_contract: rent_amount, deposit
UPDATE t_lease_contract SET rent_amount = ROUND(rent_amount * 100, 0), deposit = ROUND(deposit * 100, 0);
ALTER TABLE t_lease_contract MODIFY COLUMN rent_amount BIGINT NOT NULL DEFAULT 0 COMMENT '月租金(分)';
ALTER TABLE t_lease_contract MODIFY COLUMN deposit BIGINT NOT NULL DEFAULT 0 COMMENT '押金(分)';
-- t_workshop_lease: rent_amount
UPDATE t_workshop_lease SET rent_amount = ROUND(rent_amount * 100, 0);
ALTER TABLE t_workshop_lease MODIFY COLUMN rent_amount BIGINT NOT NULL DEFAULT 0 COMMENT '租金金额(分)';
-- t_public_revenue: amount
UPDATE t_public_revenue SET amount = ROUND(amount * 100, 0);
ALTER TABLE t_public_revenue MODIFY COLUMN amount BIGINT NOT NULL DEFAULT 0 COMMENT '收益金额(分)';
-- t_cam_charge: total_amount, allocated_amount
UPDATE t_cam_charge SET total_amount = ROUND(total_amount * 100, 0), allocated_amount = ROUND(allocated_amount * 100, 0);
ALTER TABLE t_cam_charge MODIFY COLUMN total_amount BIGINT NOT NULL DEFAULT 0 COMMENT '本期公共费用总额(分)';
ALTER TABLE t_cam_charge MODIFY COLUMN allocated_amount BIGINT NOT NULL DEFAULT 0 COMMENT '分摊金额(分)';
-- t_device: price
UPDATE t_device SET price = ROUND(price * 100, 0);
ALTER TABLE t_device MODIFY COLUMN price BIGINT NOT NULL DEFAULT 0 COMMENT '采购价格(分)';
-- t_device_maintenance: cost
UPDATE t_device_maintenance SET cost = ROUND(cost * 100, 0);
ALTER TABLE t_device_maintenance MODIFY COLUMN cost BIGINT NOT NULL DEFAULT 0 COMMENT '维保费用(分)';
-- t_renovation: deposit_amount
UPDATE t_renovation SET deposit_amount = ROUND(deposit_amount * 100, 0);
ALTER TABLE t_renovation MODIFY COLUMN deposit_amount BIGINT NOT NULL DEFAULT 0 COMMENT '装修押金(分)';

View File

@ -0,0 +1,28 @@
-- V14: 新增 phone/email hash 字段 + 索引
-- t_owner
ALTER TABLE t_owner ADD COLUMN phone_hash VARCHAR(64) DEFAULT NULL COMMENT '手机号哈希';
ALTER TABLE t_owner ADD COLUMN phone_last4_hash VARCHAR(64) DEFAULT NULL COMMENT '手机号后4位哈希';
ALTER TABLE t_owner ADD COLUMN email_hash VARCHAR(64) DEFAULT NULL COMMENT '邮箱哈希';
ALTER TABLE t_owner ADD COLUMN email_last4_hash VARCHAR(64) DEFAULT NULL COMMENT '邮箱后4位哈希';
ALTER TABLE t_owner ADD COLUMN emergency_phone_hash VARCHAR(64) DEFAULT NULL COMMENT '紧急联系电话哈希';
CREATE INDEX idx_owner_phone_hash ON t_owner(phone_hash);
CREATE INDEX idx_owner_phone_last4 ON t_owner(phone_last4_hash);
CREATE INDEX idx_owner_email_hash ON t_owner(email_hash);
-- t_tenant
ALTER TABLE t_tenant ADD COLUMN phone_hash VARCHAR(64) DEFAULT NULL COMMENT '手机号哈希';
ALTER TABLE t_tenant ADD COLUMN phone_last4_hash VARCHAR(64) DEFAULT NULL COMMENT '手机号后4位哈希';
ALTER TABLE t_tenant ADD COLUMN email_hash VARCHAR(64) DEFAULT NULL COMMENT '邮箱哈希';
ALTER TABLE t_tenant ADD COLUMN email_last4_hash VARCHAR(64) DEFAULT NULL COMMENT '邮箱后4位哈希';
CREATE INDEX idx_tenant_phone_hash ON t_tenant(phone_hash);
CREATE INDEX idx_tenant_phone_last4 ON t_tenant(phone_last4_hash);
CREATE INDEX idx_tenant_email_hash ON t_tenant(email_hash);
-- t_enterprise
ALTER TABLE t_enterprise ADD COLUMN contact_phone_hash VARCHAR(64) DEFAULT NULL COMMENT '联系电话哈希';
ALTER TABLE t_enterprise ADD COLUMN contact_phone_last4_hash VARCHAR(64) DEFAULT NULL COMMENT '联系电话后4位哈希';
ALTER TABLE t_enterprise ADD COLUMN email_hash VARCHAR(64) DEFAULT NULL COMMENT '邮箱哈希';
ALTER TABLE t_enterprise ADD COLUMN email_last4_hash VARCHAR(64) DEFAULT NULL COMMENT '邮箱后4位哈希';
CREATE INDEX idx_enterprise_phone_hash ON t_enterprise(contact_phone_hash);
CREATE INDEX idx_enterprise_phone_last4 ON t_enterprise(contact_phone_last4_hash);
CREATE INDEX idx_enterprise_email_hash ON t_enterprise(email_hash);

View File

@ -0,0 +1,83 @@
-- ========================================
-- V15: U10 pms-base 方法级鉴权权限码
-- 1. 新增 20 个 base:*:manage 功能权限perm_type=3
-- 2. ROLE_ADMINid=1关联全部 20 个权限
-- 3. ROLE_PROPERTYid=2关联 15 个核心物业管理权限
-- ========================================
-- ID 规划:
-- t_permission 300-319避开 V1 的 101-135、V6/V8 的 141-145、V3 的 201-251
-- t_role_permission 123-157接续 V8 已用 122
-- ====================
-- 1. 新增 pms-base 功能权限
-- ====================
INSERT INTO `t_permission` (`id`, `perm_code`, `perm_name`, `perm_type`, `parent_id`, `sort`, `status`, `created_at`, `updated_at`, `deleted`) VALUES
(300, 'base:owner:manage', '业主管理', 3, 0, 20, 1, UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000, 0),
(301, 'base:tenant:manage', '租户管理', 3, 0, 21, 1, UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000, 0),
(302, 'base:building:manage', '楼栋管理', 3, 0, 22, 1, UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000, 0),
(303, 'base:room:manage', '房间管理', 3, 0, 23, 1, UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000, 0),
(304, 'base:floor:manage', '楼层管理', 3, 0, 24, 1, UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000, 0),
(305, 'base:project:manage', '项目管理', 3, 0, 25, 1, UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000, 0),
(306, 'base:contract:manage', '合同管理', 3, 0, 26, 1, UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000, 0),
(307, 'base:lease:manage', '租赁合同管理', 3, 0, 27, 1, UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000, 0),
(308, 'base:device:manage', '设备管理', 3, 0, 28, 1, UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000, 0),
(309, 'base:energy:manage', '能源表计管理', 3, 0, 29, 1, UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000, 0),
(310, 'base:renovation:manage', '装修管理', 3, 0, 30, 1, UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000, 0),
(311, 'base:revenue:manage', '公共收益管理', 3, 0, 31, 1, UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000, 0),
(312, 'base:finance:manage', '财务分摊管理', 3, 0, 32, 1, UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000, 0),
(313, 'base:enterprise:manage', '企业管理', 3, 0, 33, 1, UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000, 0),
(314, 'base:committee:manage', '业委会管理', 3, 0, 34, 1, UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000, 0),
(315, 'base:workshop:manage', '厂房租赁管理', 3, 0, 35, 1, UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000, 0),
(316, 'base:meeting:manage', '会议室管理', 3, 0, 36, 1, UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000, 0),
(317, 'base:activity:manage', '社区活动管理', 3, 0, 37, 1, UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000, 0),
(318, 'base:inspection:manage', '安全检查管理', 3, 0, 38, 1, UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000, 0),
(319, 'base:lifecycle:manage', '生命周期管理', 3, 0, 39, 1, UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000, 0)
ON DUPLICATE KEY UPDATE `perm_name` = VALUES(`perm_name`);
-- ====================
-- 2. 为 ROLE_ADMINid=1关联全部 20 个 base 权限
-- ====================
INSERT INTO `t_role_permission` (`id`, `project_id`, `role_id`, `permission_id`, `created_at`, `updated_at`, `created_by`, `updated_by`, `deleted`) VALUES
(123, NULL, 1, 300, UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000, NULL, NULL, 0),
(124, NULL, 1, 301, UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000, NULL, NULL, 0),
(125, NULL, 1, 302, UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000, NULL, NULL, 0),
(126, NULL, 1, 303, UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000, NULL, NULL, 0),
(127, NULL, 1, 304, UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000, NULL, NULL, 0),
(128, NULL, 1, 305, UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000, NULL, NULL, 0),
(129, NULL, 1, 306, UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000, NULL, NULL, 0),
(130, NULL, 1, 307, UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000, NULL, NULL, 0),
(131, NULL, 1, 308, UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000, NULL, NULL, 0),
(132, NULL, 1, 309, UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000, NULL, NULL, 0),
(133, NULL, 1, 310, UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000, NULL, NULL, 0),
(134, NULL, 1, 311, UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000, NULL, NULL, 0),
(135, NULL, 1, 312, UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000, NULL, NULL, 0),
(136, NULL, 1, 313, UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000, NULL, NULL, 0),
(137, NULL, 1, 314, UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000, NULL, NULL, 0),
(138, NULL, 1, 315, UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000, NULL, NULL, 0),
(139, NULL, 1, 316, UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000, NULL, NULL, 0),
(140, NULL, 1, 317, UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000, NULL, NULL, 0),
(141, NULL, 1, 318, UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000, NULL, NULL, 0),
(142, NULL, 1, 319, UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000, NULL, NULL, 0)
ON DUPLICATE KEY UPDATE `updated_at` = VALUES(`updated_at`);
-- ====================
-- 3. 为 ROLE_PROPERTYid=2关联核心物业管理权限15 个)
-- 排除:项目、公共收益、财务分摊、企业、生命周期(管理/财务级)
-- ====================
INSERT INTO `t_role_permission` (`id`, `project_id`, `role_id`, `permission_id`, `created_at`, `updated_at`, `created_by`, `updated_by`, `deleted`) VALUES
(143, NULL, 2, 300, UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000, NULL, NULL, 0),
(144, NULL, 2, 301, UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000, NULL, NULL, 0),
(145, NULL, 2, 302, UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000, NULL, NULL, 0),
(146, NULL, 2, 303, UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000, NULL, NULL, 0),
(147, NULL, 2, 304, UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000, NULL, NULL, 0),
(148, NULL, 2, 306, UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000, NULL, NULL, 0),
(149, NULL, 2, 307, UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000, NULL, NULL, 0),
(150, NULL, 2, 308, UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000, NULL, NULL, 0),
(151, NULL, 2, 309, UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000, NULL, NULL, 0),
(152, NULL, 2, 310, UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000, NULL, NULL, 0),
(153, NULL, 2, 314, UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000, NULL, NULL, 0),
(154, NULL, 2, 315, UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000, NULL, NULL, 0),
(155, NULL, 2, 316, UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000, NULL, NULL, 0),
(156, NULL, 2, 317, UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000, NULL, NULL, 0),
(157, NULL, 2, 318, UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000, NULL, NULL, 0)
ON DUPLICATE KEY UPDATE `updated_at` = VALUES(`updated_at`);

View File

@ -7,7 +7,7 @@
SELECT c.id, c.project_id, c.contract_no, c.contract_name, c.contract_type_id,
ct.type_name AS contract_type_name, c.party_a, c.party_b,
c.party_b_type, c.party_b_id, c.room_ids, c.start_date, c.end_date,
c.sign_date, c.amount, c.deposit, c.payment_cycle, c.attachment,
c.sign_date, c.amount AS amount_fen, c.deposit AS deposit_fen, c.payment_cycle, c.attachment,
c.status, c.remark, c.created_at,
DATEDIFF(c.end_date, CURDATE()) AS days_left
FROM t_contract c
@ -38,7 +38,7 @@
SELECT c.id, c.project_id, c.contract_no, c.contract_name, c.contract_type_id,
ct.type_name AS contract_type_name, c.party_a, c.party_b,
c.party_b_type, c.party_b_id, c.room_ids, c.start_date, c.end_date,
c.sign_date, c.amount, c.deposit, c.payment_cycle, c.attachment,
c.sign_date, c.amount AS amount_fen, c.deposit AS deposit_fen, c.payment_cycle, c.attachment,
c.status, c.remark, c.created_at,
DATEDIFF(c.end_date, CURDATE()) AS days_left
FROM t_contract c
@ -51,7 +51,7 @@
SELECT c.id, c.project_id, c.contract_no, c.contract_name, c.contract_type_id,
ct.type_name AS contract_type_name, c.party_a, c.party_b,
c.party_b_type, c.party_b_id, c.room_ids, c.start_date, c.end_date,
c.sign_date, c.amount, c.deposit, c.payment_cycle, c.attachment,
c.sign_date, c.amount AS amount_fen, c.deposit AS deposit_fen, c.payment_cycle, c.attachment,
c.status, c.remark, c.created_at,
DATEDIFF(c.end_date, CURDATE()) AS days_left
FROM t_contract c

View File

@ -6,7 +6,7 @@
<select id="selectMaintenancePage" resultType="com.pms.base.dto.DeviceMaintenanceDTO">
SELECT m.id, m.project_id, m.device_id, d.device_name,
m.maintenance_type, m.maintenance_date, m.plan_date,
m.handler, m.cost, m.result, m.images, m.status, m.remark, m.created_at
m.handler, m.cost AS cost_fen, m.result, m.images, m.status, m.remark, m.created_at
FROM t_device_maintenance m
LEFT JOIN t_device d ON m.device_id = d.id AND d.deleted = 0
WHERE m.deleted = 0
@ -29,7 +29,7 @@
<select id="selectExpiringMaintenances" resultType="com.pms.base.dto.DeviceMaintenanceDTO">
SELECT m.id, m.project_id, m.device_id, d.device_name,
m.maintenance_type, m.maintenance_date, m.plan_date,
m.handler, m.cost, m.result, m.images, m.status, m.remark, m.created_at
m.handler, m.cost AS cost_fen, m.result, m.images, m.status, m.remark, m.created_at
FROM t_device_maintenance m
LEFT JOIN t_device d ON m.device_id = d.id AND d.deleted = 0
WHERE m.deleted = 0 AND m.status = 0

View File

@ -8,7 +8,7 @@
c.category_name, d.brand, d.model, d.spec, d.location_type,
d.building_id, b.building_name, d.floor_id, f.floor_name,
d.room_id, d.location_desc, d.purchase_date, d.install_date,
d.warranty_until, d.supplier, d.price, d.qr_code, d.status, d.remark
d.warranty_until, d.supplier, d.price AS price_fen, d.qr_code, d.status, d.remark
FROM t_device d
LEFT JOIN t_device_category c ON d.category_id = c.id AND c.deleted = 0
LEFT JOIN t_building b ON d.building_id = b.id AND b.deleted = 0
@ -36,7 +36,7 @@
c.category_name, d.brand, d.model, d.spec, d.location_type,
d.building_id, b.building_name, d.floor_id, f.floor_name,
d.room_id, d.location_desc, d.purchase_date, d.install_date,
d.warranty_until, d.supplier, d.price, d.qr_code, d.status, d.remark
d.warranty_until, d.supplier, d.price AS price_fen, d.qr_code, d.status, d.remark
FROM t_device d
LEFT JOIN t_device_category c ON d.category_id = c.id AND c.deleted = 0
LEFT JOIN t_building b ON d.building_id = b.id AND b.deleted = 0

View File

@ -0,0 +1,157 @@
package com.pms.base.controller;
import com.pms.common.annotation.AuditLog;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import java.lang.reflect.Method;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Stream;
import static org.assertj.core.api.Assertions.assertThat;
/**
* @AuditLog 覆盖率守卫测试
* <p>
* 反射扫描所有 Controller 写方法@PostMapping/@PutMapping/@DeleteMapping/@PatchMapping
* 断言每个写方法都标注了 @AuditLog防止后续新增写方法漏标审计
* <p>
* 豁免清单{@link #EXEMPT_WRITE_METHODS}内部 Feign 查询接口 @PostMapping 语义为查询不纳入审计
* <p>
* PII 守卫{@link #PII_METHODS}涉及身份证号/手机号/邮箱的方法必须 recordParams=false
* 防止敏感信息明文写入审计日志
*/
@DisplayName("@AuditLog 覆盖率守卫测试")
class AuditLogCoverageTest {
/** 所有业务 Controller不含 InternalController 内部 Feign 接口) */
private static final List<Class<?>> CONTROLLERS = List.of(
ApprovalFlowConfigController.class,
ArchiveController.class,
BuildingController.class,
CamChargeController.class,
CommunityActivityController.class,
ContractController.class,
ContractTypeController.class,
DeviceCategoryController.class,
DeviceController.class,
DeviceMaintenanceController.class,
EnergyMeterController.class,
EnterpriseController.class,
EnterpriseProfileController.class,
EnterpriseServiceController.class,
FloorController.class,
LeaseContractController.class,
LifecycleController.class,
MeetingRoomController.class,
OwnerCommitteeController.class,
OwnerController.class,
OwnerRoomController.class,
ProjectController.class,
PublicRevenueController.class,
RenovationController.class,
RoomController.class,
SafetyInspectionController.class,
TenantController.class,
WorkshopLeaseController.class
);
/** 豁免审计的写方法className#methodName仅内部 Feign 查询接口 */
private static final List<String> EXEMPT_WRITE_METHODS = List.of(
"InternalController#batchGetRooms"
);
/** PII 方法必须 recordParams=falseclassName#methodName */
private static final List<String> PII_METHODS = List.of(
"OwnerController#create",
"OwnerController#update",
"OwnerController#importOwners",
"TenantController#create",
"TenantController#update",
"EnterpriseController#create",
"EnterpriseController#update",
"EnterpriseProfileController#create",
"EnterpriseProfileController#update",
"EnterpriseServiceController#apply",
"EnterpriseServiceController#process",
"OwnerCommitteeController#create",
"OwnerCommitteeController#update",
"ProjectController#create",
"ProjectController#update",
"RenovationController#apply",
"WorkshopLeaseController#create",
"WorkshopLeaseController#update"
);
static Stream<Arguments> writeMethods() {
return CONTROLLERS.stream()
.flatMap(c -> Arrays.stream(c.getDeclaredMethods())
.filter(AuditLogCoverageTest::isWriteMethod)
.map(m -> Arguments.of(c.getSimpleName(), m.getName(), m)));
}
private static boolean isWriteMethod(Method m) {
return m.isAnnotationPresent(PostMapping.class)
|| m.isAnnotationPresent(PutMapping.class)
|| m.isAnnotationPresent(DeleteMapping.class)
|| m.isAnnotationPresent(PatchMapping.class);
}
@ParameterizedTest(name = "{0}#{1} 必须有 @AuditLog")
@MethodSource("writeMethods")
void writeMethod_mustHaveAuditLog(String className, String methodName, Method method) {
if (EXEMPT_WRITE_METHODS.contains(className + "#" + methodName)) {
return;
}
assertThat(method.isAnnotationPresent(AuditLog.class))
.as("%s#%s 是写方法但缺少 @AuditLog 注解", className, methodName)
.isTrue();
}
@ParameterizedTest(name = "PII 方法 {0}#{1} 必须 recordParams=false")
@MethodSource("piiMethods")
void piiMethod_mustDisableRecordParams(String className, String methodName) {
Method method = findMethod(className, methodName);
AuditLog auditLog = method.getAnnotation(AuditLog.class);
assertThat(auditLog)
.as("%s#%s 必须标注 @AuditLog", className, methodName)
.isNotNull();
assertThat(auditLog.recordParams())
.as("%s#%s 涉及 PII@AuditLog 必须 recordParams=false", className, methodName)
.isFalse();
}
static Stream<Arguments> piiMethods() {
return PII_METHODS.stream().map(s -> {
String[] parts = s.split("#");
return Arguments.of(parts[0], parts[1]);
});
}
private Method findMethod(String className, String methodName) {
return CONTROLLERS.stream()
.filter(c -> c.getSimpleName().equals(className))
.findFirst()
.flatMap(c -> Arrays.stream(c.getDeclaredMethods())
.filter(m -> m.getName().equals(methodName))
.findFirst())
.orElseThrow(() -> new IllegalStateException("Method not found: " + className + "#" + methodName));
}
@Test
@DisplayName("覆盖率统计:所有写方法均已纳入守卫")
void coverageStats() {
long totalWrite = CONTROLLERS.stream()
.mapToLong(c -> Arrays.stream(c.getDeclaredMethods()).filter(AuditLogCoverageTest::isWriteMethod).count())
.sum();
assertThat(totalWrite).isGreaterThan(0);
}
}

View File

@ -0,0 +1,35 @@
package com.pms.base.feign;
import com.pms.common.exception.ServiceCallException;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* AuthClient fallback 单元测试
* <p>
* 验证 auth-service 不可用时 fallback ServiceCallException 而非返回 null
*/
class AuthClientFallbackTest {
@Test
void getUser_fallback_throwsServiceCallException() {
AuthClient.AuthClientFallbackFactory factory = new AuthClient.AuthClientFallbackFactory();
AuthClient fallback = factory.create(new RuntimeException("connection refused"));
assertThatThrownBy(() -> fallback.getUser(1L))
.isInstanceOf(ServiceCallException.class)
.hasMessageContaining("认证服务暂不可用");
}
@Test
void getUser_fallback_preservesCause() {
Throwable cause = new RuntimeException("timeout");
AuthClient.AuthClientFallbackFactory factory = new AuthClient.AuthClientFallbackFactory();
AuthClient fallback = factory.create(cause);
assertThatThrownBy(() -> fallback.getUser(99L))
.isInstanceOf(ServiceCallException.class)
.hasCause(cause);
}
}

View File

@ -13,6 +13,7 @@ import com.pms.base.mapper.BuildingMapper;
import com.pms.base.mapper.FloorMapper;
import com.pms.base.mapper.RoomMapper;
import com.pms.base.service.impl.BuildingServiceImpl;
import com.pms.base.util.LifecycleWriteGuard;
import com.pms.common.exception.BusinessException;
import com.pms.common.exception.ErrorCode;
import com.pms.common.response.PageResult;
@ -54,6 +55,9 @@ class BuildingServiceTest {
@Mock
private RoomMapper roomMapper;
@Mock
private LifecycleWriteGuard lifecycleWriteGuard;
@InjectMocks
private BuildingServiceImpl buildingService;

View File

@ -0,0 +1,162 @@
package com.pms.base.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.pms.base.dto.ContractRenewRequest;
import com.pms.base.dto.ContractSaveRequest;
import com.pms.base.entity.Contract;
import com.pms.base.mapper.ContractMapper;
import com.pms.base.service.impl.ContractServiceImpl;
import com.pms.base.util.LifecycleWriteGuard;
import com.pms.common.exception.BusinessException;
import com.pms.common.security.UserContext;
import org.junit.jupiter.api.*;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import java.time.LocalDate;
import java.util.List;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
/**
* 合同服务测试
*/
@DisplayName("合同服务测试")
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class ContractServiceImplTest {
private static final Long PROJECT_ID = 2001L;
private static final Long USER_ID = 1001L;
@Mock
private ContractMapper contractMapper;
@Mock
private LifecycleWriteGuard lifecycleWriteGuard;
@InjectMocks
private ContractServiceImpl contractService;
@BeforeEach
void setUp() {
UserContext.set(new UserContext.CurrentUser(USER_ID, "admin", PROJECT_ID, "ROLE_ADMIN", "1"));
}
@AfterEach
void tearDown() {
UserContext.clear();
}
@Test
@DisplayName("创建合同:编号重复抛 RESOURCE_EXISTS")
void create_duplicateContractNo_throwsException() {
when(contractMapper.selectCount(any(LambdaQueryWrapper.class))).thenReturn(1L);
ContractSaveRequest request = new ContractSaveRequest();
request.setContractNo("C001");
request.setContractName("租赁合同");
assertThatThrownBy(() -> contractService.create(request))
.isInstanceOf(BusinessException.class)
.hasMessageContaining("合同编号已存在");
verify(contractMapper, never()).insert(any());
}
@Test
@DisplayName("创建合同:成功返回 IDroomIds 转逗号分隔,金额(分)正确")
void create_normal_returnsId() {
when(contractMapper.selectCount(any(LambdaQueryWrapper.class))).thenReturn(0L);
when(contractMapper.insert(any(Contract.class))).thenAnswer(inv -> {
Contract c = inv.getArgument(0);
c.setId(6001L);
return 1;
});
ContractSaveRequest request = new ContractSaveRequest();
request.setContractNo("C001");
request.setContractName("租赁合同");
request.setRoomIds(List.of(101L, 102L, 103L));
request.setAmountFen(1200000L);
request.setDepositFen(200000L);
Long id = contractService.create(request);
assertThat(id).isEqualTo(6001L);
ArgumentCaptor<Contract> captor = ArgumentCaptor.forClass(Contract.class);
verify(contractMapper).insert(captor.capture());
Contract saved = captor.getValue();
assertThat(saved.getContractNo()).isEqualTo("C001");
assertThat(saved.getRoomIds()).isEqualTo("101,102,103");
assertThat(saved.getAmountFen()).isEqualTo(1200000L);
assertThat(saved.getDepositFen()).isEqualTo(200000L);
assertThat(saved.getProjectId()).isEqualTo(PROJECT_ID);
assertThat(saved.getStatus()).isEqualTo(0);
}
@Test
@DisplayName("查询合同:不存在抛异常")
void getById_notFound_throwsException() {
when(contractMapper.selectContractDetail(999L)).thenReturn(null);
assertThatThrownBy(() -> contractService.getById(999L))
.isInstanceOf(BusinessException.class);
}
@Test
@DisplayName("续签合同:原合同置为续签态(4)并创建新合同")
void renew_normal_createsNewContract() {
Contract existing = new Contract();
existing.setId(6001L);
existing.setProjectId(PROJECT_ID);
existing.setContractNo("C001");
existing.setContractName("租赁合同");
existing.setEndDate(LocalDate.of(2026, 12, 31));
existing.setAmountFen(1200000L);
existing.setDepositFen(200000L);
when(contractMapper.selectById(6001L)).thenReturn(existing);
when(contractMapper.insert(any(Contract.class))).thenAnswer(inv -> {
Contract c = inv.getArgument(0);
c.setId(6002L);
return 1;
});
ContractRenewRequest request = new ContractRenewRequest();
request.setNewEndDate(LocalDate.of(2027, 12, 31));
request.setSignDate(LocalDate.of(2026, 11, 1));
Long newId = contractService.renew(6001L, request);
assertThat(newId).isEqualTo(6002L);
// 原合同状态置为 4
ArgumentCaptor<Contract> updateCaptor = ArgumentCaptor.forClass(Contract.class);
verify(contractMapper).updateById(updateCaptor.capture());
assertThat(updateCaptor.getValue().getStatus()).isEqualTo(4);
// 新合同插入
ArgumentCaptor<Contract> insertCaptor = ArgumentCaptor.forClass(Contract.class);
verify(contractMapper).insert(insertCaptor.capture());
Contract newContract = insertCaptor.getValue();
assertThat(newContract.getContractNo()).isEqualTo("C001-R");
assertThat(newContract.getStatus()).isEqualTo(1);
assertThat(newContract.getStartDate()).isEqualTo(LocalDate.of(2026, 12, 31));
}
@Test
@DisplayName("删除合同:不存在抛异常")
void delete_notFound_throwsException() {
when(contractMapper.selectById(999L)).thenReturn(null);
assertThatThrownBy(() -> contractService.delete(999L))
.isInstanceOf(BusinessException.class);
verify(contractMapper, never()).deleteById(anyLong());
}
}

View File

@ -0,0 +1,113 @@
package com.pms.base.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.pms.base.dto.ContractTypeSaveRequest;
import com.pms.base.entity.ContractType;
import com.pms.base.mapper.ContractTypeMapper;
import com.pms.base.service.impl.ContractTypeServiceImpl;
import com.pms.common.exception.BusinessException;
import com.pms.common.security.UserContext;
import org.junit.jupiter.api.*;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
/**
* 合同类型服务测试
*/
@DisplayName("合同类型服务测试")
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class ContractTypeServiceImplTest {
private static final Long PROJECT_ID = 2001L;
private static final Long USER_ID = 1001L;
@Mock
private ContractTypeMapper contractTypeMapper;
@InjectMocks
private ContractTypeServiceImpl contractTypeService;
@BeforeEach
void setUp() {
UserContext.set(new UserContext.CurrentUser(USER_ID, "admin", PROJECT_ID, "ROLE_ADMIN", "1"));
}
@AfterEach
void tearDown() {
UserContext.clear();
}
@Test
@DisplayName("创建合同类型:编码重复抛 RESOURCE_EXISTS")
void create_duplicateCode_throwsException() {
when(contractTypeMapper.selectCount(any(LambdaQueryWrapper.class))).thenReturn(1L);
ContractTypeSaveRequest request = new ContractTypeSaveRequest();
request.setTypeCode("CT001");
request.setTypeName("租赁合同");
assertThatThrownBy(() -> contractTypeService.create(request))
.isInstanceOf(BusinessException.class)
.hasMessageContaining("类型编码已存在");
verify(contractTypeMapper, never()).insert(any());
}
@Test
@DisplayName("创建合同类型:成功返回 ID")
void create_normal_returnsId() {
when(contractTypeMapper.selectCount(any(LambdaQueryWrapper.class))).thenReturn(0L);
when(contractTypeMapper.insert(any(ContractType.class))).thenAnswer(inv -> {
ContractType t = inv.getArgument(0);
t.setId(4001L);
return 1;
});
ContractTypeSaveRequest request = new ContractTypeSaveRequest();
request.setTypeCode("CT001");
request.setTypeName("租赁合同");
Long id = contractTypeService.create(request);
assertThat(id).isEqualTo(4001L);
ArgumentCaptor<ContractType> captor = ArgumentCaptor.forClass(ContractType.class);
verify(contractTypeMapper).insert(captor.capture());
ContractType saved = captor.getValue();
assertThat(saved.getTypeCode()).isEqualTo("CT001");
assertThat(saved.getProjectId()).isEqualTo(PROJECT_ID);
assertThat(saved.getCreatedBy()).isEqualTo(USER_ID);
assertThat(saved.getStatus()).isEqualTo(1);
}
@Test
@DisplayName("更新合同类型:不存在抛异常")
void update_notFound_throwsException() {
when(contractTypeMapper.selectById(999L)).thenReturn(null);
assertThatThrownBy(() -> contractTypeService.update(999L, new ContractTypeSaveRequest()))
.isInstanceOf(BusinessException.class);
verify(contractTypeMapper, never()).updateById(any());
}
@Test
@DisplayName("删除合同类型:不存在抛异常")
void delete_notFound_throwsException() {
when(contractTypeMapper.selectById(999L)).thenReturn(null);
assertThatThrownBy(() -> contractTypeService.delete(999L))
.isInstanceOf(BusinessException.class);
verify(contractTypeMapper, never()).deleteById(anyLong());
}
}

View File

@ -0,0 +1,146 @@
package com.pms.base.service;
import com.pms.base.dto.DeviceCategoryDTO;
import com.pms.base.dto.DeviceCategorySaveRequest;
import com.pms.base.entity.DeviceCategory;
import com.pms.base.mapper.DeviceCategoryMapper;
import com.pms.base.service.impl.DeviceCategoryServiceImpl;
import com.pms.common.exception.BusinessException;
import com.pms.common.security.UserContext;
import org.junit.jupiter.api.*;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import java.util.List;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.Mockito.*;
/**
* 设备分类服务测试
*/
@DisplayName("设备分类服务测试")
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class DeviceCategoryServiceImplTest {
private static final Long PROJECT_ID = 2001L;
private static final Long USER_ID = 1001L;
@Mock
private DeviceCategoryMapper deviceCategoryMapper;
@InjectMocks
private DeviceCategoryServiceImpl deviceCategoryService;
@BeforeEach
void setUp() {
UserContext.set(new UserContext.CurrentUser(USER_ID, "admin", PROJECT_ID, "ROLE_ADMIN", "1"));
}
@AfterEach
void tearDown() {
UserContext.clear();
}
@Test
@DisplayName("创建分类parentId 为空时默认 0")
void create_normal_returnsId() {
when(deviceCategoryMapper.insert(any(DeviceCategory.class))).thenAnswer(inv -> {
DeviceCategory c = inv.getArgument(0);
c.setId(3001L);
return 1;
});
DeviceCategorySaveRequest request = new DeviceCategorySaveRequest();
request.setCategoryCode("DC001");
request.setCategoryName("电梯设备");
Long id = deviceCategoryService.create(request);
assertThat(id).isEqualTo(3001L);
ArgumentCaptor<DeviceCategory> captor = ArgumentCaptor.forClass(DeviceCategory.class);
verify(deviceCategoryMapper).insert(captor.capture());
DeviceCategory saved = captor.getValue();
assertThat(saved.getParentId()).isEqualTo(0L);
assertThat(saved.getProjectId()).isEqualTo(PROJECT_ID);
assertThat(saved.getCategoryCode()).isEqualTo("DC001");
assertThat(saved.getStatus()).isEqualTo(1);
}
@Test
@DisplayName("更新分类:不存在抛异常")
void update_notFound_throwsException() {
when(deviceCategoryMapper.selectById(999L)).thenReturn(null);
assertThatThrownBy(() -> deviceCategoryService.update(999L, new DeviceCategorySaveRequest()))
.isInstanceOf(BusinessException.class);
verify(deviceCategoryMapper, never()).updateById(any());
}
@Test
@DisplayName("删除分类:存在子分类时抛异常")
void delete_withChildren_throwsException() {
DeviceCategory existing = new DeviceCategory();
existing.setId(1L);
when(deviceCategoryMapper.selectById(1L)).thenReturn(existing);
when(deviceCategoryMapper.countChildren(1L)).thenReturn(2);
assertThatThrownBy(() -> deviceCategoryService.delete(1L))
.isInstanceOf(BusinessException.class)
.hasMessageContaining("存在子分类");
verify(deviceCategoryMapper, never()).deleteById(anyLong());
}
@Test
@DisplayName("删除分类:无子分类时成功")
void delete_normal_success() {
DeviceCategory existing = new DeviceCategory();
existing.setId(1L);
when(deviceCategoryMapper.selectById(1L)).thenReturn(existing);
when(deviceCategoryMapper.countChildren(1L)).thenReturn(0);
deviceCategoryService.delete(1L);
verify(deviceCategoryMapper).deleteById(1L);
}
@Test
@DisplayName("tree构建父子层级结构")
void tree_normal_returnsHierarchy() {
DeviceCategory root = new DeviceCategory();
root.setId(1L);
root.setParentId(0L);
root.setCategoryCode("ROOT");
root.setCategoryName("根分类");
root.setSort(1);
root.setStatus(1);
DeviceCategory child = new DeviceCategory();
child.setId(2L);
child.setParentId(1L);
child.setCategoryCode("CHILD");
child.setCategoryName("子分类");
child.setSort(1);
child.setStatus(1);
when(deviceCategoryMapper.selectAllCategories(PROJECT_ID))
.thenReturn(List.of(root, child));
List<DeviceCategoryDTO> tree = deviceCategoryService.tree(PROJECT_ID);
assertThat(tree).hasSize(1);
assertThat(tree.get(0).getCategoryName()).isEqualTo("根分类");
assertThat(tree.get(0).getChildren()).hasSize(1);
assertThat(tree.get(0).getChildren().get(0).getCategoryName()).isEqualTo("子分类");
}
}

View File

@ -0,0 +1,148 @@
package com.pms.base.service;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.pms.base.dto.DeviceMaintenanceDTO;
import com.pms.base.dto.DeviceMaintenanceSaveRequest;
import com.pms.base.entity.Device;
import com.pms.base.entity.DeviceMaintenance;
import com.pms.base.mapper.DeviceMaintenanceMapper;
import com.pms.base.mapper.DeviceMapper;
import com.pms.base.service.impl.DeviceMaintenanceServiceImpl;
import com.pms.base.util.LifecycleWriteGuard;
import com.pms.common.exception.BusinessException;
import com.pms.common.response.PageResult;
import com.pms.common.security.UserContext;
import org.junit.jupiter.api.*;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import java.util.List;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
/**
* 设备维保服务测试
*/
@DisplayName("设备维保服务测试")
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class DeviceMaintenanceServiceImplTest {
private static final Long PROJECT_ID = 2001L;
private static final Long USER_ID = 1001L;
@Mock
private DeviceMaintenanceMapper maintenanceMapper;
@Mock
private DeviceMapper deviceMapper;
@Mock
private LifecycleWriteGuard lifecycleWriteGuard;
@InjectMocks
private DeviceMaintenanceServiceImpl maintenanceService;
@BeforeEach
void setUp() {
UserContext.set(new UserContext.CurrentUser(USER_ID, "admin", PROJECT_ID, "ROLE_ADMIN", "1"));
}
@AfterEach
void tearDown() {
UserContext.clear();
}
@Test
@DisplayName("创建维保记录:设备不存在抛异常")
void create_deviceNotFound_throwsException() {
when(deviceMapper.selectById(999L)).thenReturn(null);
assertThatThrownBy(() -> maintenanceService.create(999L, new DeviceMaintenanceSaveRequest()))
.isInstanceOf(BusinessException.class);
verify(maintenanceMapper, never()).insert(any());
}
@Test
@DisplayName("创建维保记录:成功返回 IDprojectId 取自设备")
void create_normal_returnsId() {
Device device = new Device();
device.setId(9001L);
device.setProjectId(PROJECT_ID);
device.setDeviceName("电梯A");
when(deviceMapper.selectById(9001L)).thenReturn(device);
when(maintenanceMapper.insert(any(DeviceMaintenance.class))).thenAnswer(inv -> {
DeviceMaintenance m = inv.getArgument(0);
m.setId(5001L);
return 1;
});
DeviceMaintenanceSaveRequest request = new DeviceMaintenanceSaveRequest();
request.setMaintenanceType(1);
request.setCostFen(20000L);
request.setImages(List.of("img1.jpg", "img2.jpg"));
Long id = maintenanceService.create(9001L, request);
assertThat(id).isEqualTo(5001L);
ArgumentCaptor<DeviceMaintenance> captor = ArgumentCaptor.forClass(DeviceMaintenance.class);
verify(maintenanceMapper).insert(captor.capture());
DeviceMaintenance saved = captor.getValue();
assertThat(saved.getDeviceId()).isEqualTo(9001L);
assertThat(saved.getProjectId()).isEqualTo(PROJECT_ID);
assertThat(saved.getCostFen()).isEqualTo(20000L);
assertThat(saved.getImages()).isEqualTo("img1.jpg,img2.jpg");
assertThat(saved.getStatus()).isEqualTo(0);
}
@Test
@DisplayName("查询维保详情:填充设备名称与图片列表")
void getById_fillsDeviceName() {
DeviceMaintenance maintenance = new DeviceMaintenance();
maintenance.setId(5001L);
maintenance.setProjectId(PROJECT_ID);
maintenance.setDeviceId(9001L);
maintenance.setImages("a.jpg,b.jpg");
when(maintenanceMapper.selectById(5001L)).thenReturn(maintenance);
Device device = new Device();
device.setId(9001L);
device.setDeviceName("电梯A");
when(deviceMapper.selectById(9001L)).thenReturn(device);
DeviceMaintenanceDTO result = maintenanceService.getById(5001L);
assertThat(result.getDeviceName()).isEqualTo("电梯A");
assertThat(result.getImageList()).containsExactly("a.jpg", "b.jpg");
}
@Test
@DisplayName("分页查询返回结果")
void page_normal_returnsResult() {
DeviceMaintenanceDTO query = new DeviceMaintenanceDTO();
query.setPage(1);
query.setSize(10);
DeviceMaintenanceDTO dto = new DeviceMaintenanceDTO();
dto.setId(5001L);
Page<DeviceMaintenanceDTO> mockPage = new Page<>(1, 10);
mockPage.setRecords(List.of(dto));
mockPage.setTotal(1);
when(maintenanceMapper.selectMaintenancePage(any(Page.class), any(), any(), any(), any()))
.thenReturn(mockPage);
PageResult<DeviceMaintenanceDTO> result = maintenanceService.page(query, null);
assertThat(result.getList()).hasSize(1);
assertThat(result.getTotal()).isEqualTo(1);
}
}

View File

@ -0,0 +1,139 @@
package com.pms.base.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.pms.base.dto.DeviceDTO;
import com.pms.base.dto.DeviceSaveRequest;
import com.pms.base.entity.Device;
import com.pms.base.mapper.DeviceMapper;
import com.pms.base.service.impl.DeviceServiceImpl;
import com.pms.base.util.LifecycleWriteGuard;
import com.pms.common.exception.BusinessException;
import com.pms.common.response.PageResult;
import com.pms.common.security.UserContext;
import org.junit.jupiter.api.*;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import java.util.List;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
/**
* 设备服务测试
*/
@DisplayName("设备服务测试")
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class DeviceServiceImplTest {
private static final Long PROJECT_ID = 2001L;
private static final Long USER_ID = 1001L;
@Mock
private DeviceMapper deviceMapper;
@Mock
private LifecycleWriteGuard lifecycleWriteGuard;
@InjectMocks
private DeviceServiceImpl deviceService;
@BeforeEach
void setUp() {
UserContext.set(new UserContext.CurrentUser(USER_ID, "admin", PROJECT_ID, "ROLE_ADMIN", "1"));
}
@AfterEach
void tearDown() {
UserContext.clear();
}
@Test
@DisplayName("创建设备:编号重复抛 RESOURCE_EXISTS")
void create_duplicateCode_throwsException() {
when(deviceMapper.selectCount(any(LambdaQueryWrapper.class))).thenReturn(1L);
DeviceSaveRequest request = new DeviceSaveRequest();
request.setDeviceCode("D001");
request.setDeviceName("电梯A");
assertThatThrownBy(() -> deviceService.create(request))
.isInstanceOf(BusinessException.class)
.hasMessageContaining("设备编号已存在");
verify(deviceMapper, never()).insert(any());
}
@Test
@DisplayName("创建设备:成功返回 ID 且金额字段(分)正确传递")
void create_normal_returnsId() {
when(deviceMapper.selectCount(any(LambdaQueryWrapper.class))).thenReturn(0L);
when(deviceMapper.insert(any(Device.class))).thenAnswer(inv -> {
Device d = inv.getArgument(0);
d.setId(9001L);
return 1;
});
DeviceSaveRequest request = new DeviceSaveRequest();
request.setDeviceCode("D001");
request.setDeviceName("电梯A");
request.setPriceFen(5000000L); // 50000.00
Long id = deviceService.create(request);
assertThat(id).isEqualTo(9001L);
ArgumentCaptor<Device> captor = ArgumentCaptor.forClass(Device.class);
verify(deviceMapper).insert(captor.capture());
Device saved = captor.getValue();
assertThat(saved.getDeviceCode()).isEqualTo("D001");
assertThat(saved.getPriceFen()).isEqualTo(5000000L);
assertThat(saved.getProjectId()).isEqualTo(PROJECT_ID);
assertThat(saved.getCreatedBy()).isEqualTo(USER_ID);
assertThat(saved.getStatus()).isEqualTo(1);
}
@Test
@DisplayName("更新设备:不存在抛异常")
void update_notFound_throwsException() {
when(deviceMapper.selectById(999L)).thenReturn(null);
assertThatThrownBy(() -> deviceService.update(999L, new DeviceSaveRequest()))
.isInstanceOf(BusinessException.class);
verify(deviceMapper, never()).updateById(any());
}
@Test
@DisplayName("删除设备:不存在抛异常")
void delete_notFound_throwsException() {
when(deviceMapper.selectById(999L)).thenReturn(null);
assertThatThrownBy(() -> deviceService.delete(999L))
.isInstanceOf(BusinessException.class);
verify(deviceMapper, never()).deleteById(anyLong());
}
@Test
@DisplayName("更新设备状态:成功更新 status 字段")
void updateStatus_normal_success() {
Device existing = new Device();
existing.setId(9001L);
when(deviceMapper.selectById(9001L)).thenReturn(existing);
deviceService.updateStatus(9001L, 3);
ArgumentCaptor<Device> captor = ArgumentCaptor.forClass(Device.class);
verify(deviceMapper).updateById(captor.capture());
assertThat(captor.getValue().getStatus()).isEqualTo(3);
assertThat(captor.getValue().getUpdatedBy()).isEqualTo(USER_ID);
}
}

View File

@ -0,0 +1,171 @@
package com.pms.base.service;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.pms.base.dto.EnterpriseDTO;
import com.pms.base.dto.EnterpriseSaveRequest;
import com.pms.base.entity.Enterprise;
import com.pms.base.mapper.EnterpriseMapper;
import com.pms.base.service.impl.EnterpriseServiceImpl;
import com.pms.base.util.LifecycleWriteGuard;
import com.pms.common.exception.BusinessException;
import com.pms.common.response.PageResult;
import com.pms.common.security.UserContext;
import com.pms.common.util.CryptoUtil;
import org.junit.jupiter.api.*;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import java.util.List;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.*;
/**
* 企业服务测试含联系电话/邮箱加密验证
*/
@DisplayName("企业服务测试")
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class EnterpriseServiceImplTest {
private static final Long PROJECT_ID = 2001L;
private static final Long USER_ID = 1001L;
@Mock
private EnterpriseMapper enterpriseMapper;
@Mock
private CryptoUtil cryptoUtil;
@Mock
private LifecycleWriteGuard lifecycleWriteGuard;
@InjectMocks
private EnterpriseServiceImpl enterpriseService;
@BeforeEach
void setUp() {
UserContext.set(new UserContext.CurrentUser(USER_ID, "admin", PROJECT_ID, "ROLE_ADMIN", "1"));
}
@AfterEach
void tearDown() {
UserContext.clear();
}
@Test
@DisplayName("创建企业:联系电话/邮箱加密并填充 hash")
void create_withContactPhoneAndEmail_encrypts() {
when(cryptoUtil.encrypt(anyString())).thenReturn("encrypted");
when(cryptoUtil.hash(anyString())).thenReturn("hash");
when(enterpriseMapper.insert(any(Enterprise.class))).thenAnswer(inv -> {
Enterprise e = inv.getArgument(0);
e.setId(7001L);
return 1;
});
EnterpriseSaveRequest request = new EnterpriseSaveRequest();
request.setEnterpriseName("智造科技");
request.setCreditCode("91110000MA01");
request.setLegalPerson("李四");
request.setContactPerson("王五");
request.setContactPhone("13800001111");
request.setEmail("a@b.com");
Long id = enterpriseService.create(request);
assertThat(id).isEqualTo(7001L);
ArgumentCaptor<Enterprise> captor = ArgumentCaptor.forClass(Enterprise.class);
verify(enterpriseMapper).insert(captor.capture());
Enterprise saved = captor.getValue();
assertThat(saved.getProjectId()).isEqualTo(PROJECT_ID);
assertThat(saved.getEnterpriseName()).isEqualTo("智造科技");
assertThat(saved.getContactPhone()).isEqualTo("encrypted");
assertThat(saved.getContactPhoneHash()).isEqualTo("hash");
assertThat(saved.getContactPhoneLast4Hash()).isEqualTo("hash");
assertThat(saved.getEmail()).isEqualTo("encrypted");
assertThat(saved.getEmailLast4Hash()).isEqualTo("hash");
assertThat(saved.getCreatedBy()).isEqualTo(USER_ID);
assertThat(saved.getStatus()).isEqualTo(1);
}
@Test
@DisplayName("查询企业详情:解密联系电话/邮箱")
void getById_decryptsContactPhoneAndEmail() {
Enterprise entity = new Enterprise();
entity.setId(7001L);
entity.setProjectId(PROJECT_ID);
entity.setEnterpriseName("智造科技");
entity.setContactPhone("enc-phone");
entity.setEmail("enc-email");
when(enterpriseMapper.selectById(7001L)).thenReturn(entity);
when(cryptoUtil.decrypt(anyString())).thenReturn("decrypted");
EnterpriseDTO result = enterpriseService.getById(7001L);
assertThat(result.getContactPhone()).isEqualTo("decrypted");
assertThat(result.getEmail()).isEqualTo("decrypted");
assertThat(result.getEnterpriseName()).isEqualTo("智造科技");
}
@Test
@DisplayName("查询企业:不存在抛异常")
void getById_notFound_throwsException() {
when(enterpriseMapper.selectById(999L)).thenReturn(null);
assertThatThrownBy(() -> enterpriseService.getById(999L))
.isInstanceOf(BusinessException.class);
}
@Test
@DisplayName("更新企业:不存在抛异常")
void update_notFound_throwsException() {
when(enterpriseMapper.selectById(999L)).thenReturn(null);
assertThatThrownBy(() -> enterpriseService.update(999L, new EnterpriseSaveRequest()))
.isInstanceOf(BusinessException.class);
verify(enterpriseMapper, never()).updateById(any());
}
@Test
@DisplayName("删除企业:不存在抛异常")
void delete_notFound_throwsException() {
when(enterpriseMapper.selectById(999L)).thenReturn(null);
assertThatThrownBy(() -> enterpriseService.delete(999L))
.isInstanceOf(BusinessException.class);
verify(enterpriseMapper, never()).deleteById(anyLong());
}
@Test
@DisplayName("分页查询返回结果")
void page_normal_returnsResult() {
EnterpriseDTO query = new EnterpriseDTO();
query.setPage(1);
query.setSize(10);
EnterpriseDTO dto = new EnterpriseDTO();
dto.setId(1L);
dto.setEnterpriseName("智造科技");
Page<EnterpriseDTO> mockPage = new Page<>(1, 10);
mockPage.setRecords(List.of(dto));
mockPage.setTotal(1);
when(enterpriseMapper.selectEnterprisePage(any(Page.class), any(), any()))
.thenReturn(mockPage);
PageResult<EnterpriseDTO> result = enterpriseService.page(query);
assertThat(result.getList()).hasSize(1);
assertThat(result.getTotal()).isEqualTo(1);
}
}

View File

@ -0,0 +1,148 @@
package com.pms.base.service;
import com.pms.base.dto.FloorSaveRequest;
import com.pms.base.entity.Building;
import com.pms.base.entity.Floor;
import com.pms.base.mapper.BuildingMapper;
import com.pms.base.mapper.FloorMapper;
import com.pms.base.service.impl.FloorServiceImpl;
import com.pms.base.util.LifecycleWriteGuard;
import com.pms.common.exception.BusinessException;
import com.pms.common.security.UserContext;
import org.junit.jupiter.api.*;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
/**
* 楼层服务测试
*/
@DisplayName("楼层服务测试")
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class FloorServiceImplTest {
private static final Long PROJECT_ID = 2001L;
private static final Long USER_ID = 1001L;
@Mock
private FloorMapper floorMapper;
@Mock
private BuildingMapper buildingMapper;
@Mock
private LifecycleWriteGuard lifecycleWriteGuard;
@InjectMocks
private FloorServiceImpl floorService;
@BeforeEach
void setUp() {
UserContext.set(new UserContext.CurrentUser(USER_ID, "admin", PROJECT_ID, "ROLE_ADMIN", "1"));
}
@AfterEach
void tearDown() {
UserContext.clear();
}
@Test
@DisplayName("创建楼层:楼栋不存在抛 BUILDING_NOT_FOUND")
void create_buildingNotFound_throwsException() {
when(buildingMapper.selectById(999L)).thenReturn(null);
FloorSaveRequest request = new FloorSaveRequest();
request.setBuildingId(999L);
request.setFloorNo(1);
assertThatThrownBy(() -> floorService.create(request))
.isInstanceOf(BusinessException.class);
verify(floorMapper, never()).insert(any());
}
@Test
@DisplayName("创建楼层:未提供名称时自动生成 F/B 前缀")
void create_normal_autoGeneratesFloorName() {
Building building = new Building();
building.setId(100L);
building.setProjectId(PROJECT_ID);
when(buildingMapper.selectById(100L)).thenReturn(building);
when(floorMapper.insert(any(Floor.class))).thenAnswer(inv -> {
Floor f = inv.getArgument(0);
f.setId(2001L);
return 1;
});
// 地上楼层
FloorSaveRequest up = new FloorSaveRequest();
up.setBuildingId(100L);
up.setFloorNo(3);
Long upId = floorService.create(up);
assertThat(upId).isEqualTo(2001L);
ArgumentCaptor<Floor> captor = ArgumentCaptor.forClass(Floor.class);
verify(floorMapper).insert(captor.capture());
Floor savedUp = captor.getValue();
assertThat(savedUp.getFloorName()).isEqualTo("F3");
assertThat(savedUp.getFloorType()).isEqualTo(1);
assertThat(savedUp.getProjectId()).isEqualTo(PROJECT_ID);
assertThat(savedUp.getCreatedBy()).isEqualTo(USER_ID);
}
@Test
@DisplayName("创建楼层:地下楼层自动生成 B 前缀且 floorType=2")
void create_undergroundFloor_autoGeneratesBPrefix() {
Building building = new Building();
building.setId(100L);
building.setProjectId(PROJECT_ID);
when(buildingMapper.selectById(100L)).thenReturn(building);
when(floorMapper.insert(any(Floor.class))).thenAnswer(inv -> {
Floor f = inv.getArgument(0);
f.setId(2002L);
return 1;
});
FloorSaveRequest down = new FloorSaveRequest();
down.setBuildingId(100L);
down.setFloorNo(-1);
floorService.create(down);
ArgumentCaptor<Floor> captor = ArgumentCaptor.forClass(Floor.class);
verify(floorMapper).insert(captor.capture());
Floor saved = captor.getValue();
assertThat(saved.getFloorName()).isEqualTo("B1");
assertThat(saved.getFloorType()).isEqualTo(2);
}
@Test
@DisplayName("更新楼层:不存在抛异常")
void update_notFound_throwsException() {
when(floorMapper.selectById(999L)).thenReturn(null);
assertThatThrownBy(() -> floorService.update(999L, new FloorSaveRequest()))
.isInstanceOf(BusinessException.class);
verify(floorMapper, never()).updateById(any());
}
@Test
@DisplayName("删除楼层:不存在抛异常")
void delete_notFound_throwsException() {
when(floorMapper.selectById(999L)).thenReturn(null);
assertThatThrownBy(() -> floorService.delete(999L))
.isInstanceOf(BusinessException.class);
verify(floorMapper, never()).deleteById(anyLong());
}
}

View File

@ -13,6 +13,7 @@ import com.pms.base.service.impl.CamChargeServiceImpl;
import com.pms.base.service.impl.EnterpriseServiceServiceImpl;
import com.pms.base.service.impl.LeaseContractServiceImpl;
import com.pms.base.service.impl.MeetingRoomServiceImpl;
import com.pms.base.util.LifecycleWriteGuard;
import com.pms.common.security.UserContext;
import org.junit.jupiter.api.*;
import org.junit.jupiter.api.extension.ExtendWith;
@ -59,6 +60,9 @@ class OfficeT2FlowTest {
@Mock
private LeaseContractService leaseContractService;
@Mock
private LifecycleWriteGuard lifecycleWriteGuard;
@InjectMocks
private LeaseContractServiceImpl leaseContractServiceImpl;
@ -144,7 +148,7 @@ class OfficeT2FlowTest {
// when 分摊总额 1000
List<CamCharge> result = camChargeService.allocateCamCharge(
PROJECT_ID, new BigDecimal("1000"), "2024-01");
PROJECT_ID, 100000L, "2024-01");
// then 生成 3 条分摊记录
assertThat(result).hasSize(3);
@ -152,20 +156,20 @@ class OfficeT2FlowTest {
verify(camChargeMapper, times(3)).insert(captor.capture());
List<CamCharge> charges = captor.getAllValues();
// 面积100100/600=0.1667 1000*0.1667=166.70
assertThat(charges.get(0).getAllocatedAmount()).isEqualByComparingTo("166.70");
// 面积100100/600=0.1667 100000*0.1667=16670
assertThat(charges.get(0).getAllocatedAmountFen()).isEqualTo(16670L);
assertThat(charges.get(0).getRatio()).isEqualByComparingTo("0.1667");
assertThat(charges.get(0).getContractId()).isEqualTo(1001L);
// 面积200200/600=0.3333 1000*0.3333=333.30
assertThat(charges.get(1).getAllocatedAmount()).isEqualByComparingTo("333.30");
// 面积300300/600=0.5 1000*0.5=500.00
assertThat(charges.get(2).getAllocatedAmount()).isEqualByComparingTo("500.00");
// 面积200200/600=0.3333 100000*0.3333=33330
assertThat(charges.get(1).getAllocatedAmountFen()).isEqualTo(33330L);
// 面积300300/600=0.5 100000*0.5=50000
assertThat(charges.get(2).getAllocatedAmountFen()).isEqualTo(50000L);
// 分摊金额总和 = 1000
BigDecimal sum = charges.stream()
.map(CamCharge::getAllocatedAmount)
.reduce(BigDecimal.ZERO, BigDecimal::add);
assertThat(sum).isEqualByComparingTo("1000.00");
// 分摊金额总和 = 100000
long sum = charges.stream()
.mapToLong(CamCharge::getAllocatedAmountFen)
.sum();
assertThat(sum).isEqualTo(100000L);
// 周期与状态
assertThat(charges.get(0).getChargePeriod()).isEqualTo("2024-01");
@ -179,7 +183,7 @@ class OfficeT2FlowTest {
.thenReturn(List.of());
assertThatThrownBy(() -> camChargeService.allocateCamCharge(
PROJECT_ID, new BigDecimal("1000"), "2024-01"))
PROJECT_ID, 100000L, "2024-01"))
.isInstanceOf(com.pms.common.exception.BusinessException.class);
verify(camChargeMapper, never()).insert(any());

View File

@ -0,0 +1,167 @@
package com.pms.base.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.pms.base.dto.OwnerRoomRelRequest;
import com.pms.base.entity.Owner;
import com.pms.base.entity.OwnerRoom;
import com.pms.base.entity.Room;
import com.pms.base.mapper.OwnerMapper;
import com.pms.base.mapper.OwnerRoomMapper;
import com.pms.base.mapper.RoomMapper;
import com.pms.base.service.impl.OwnerRoomServiceImpl;
import com.pms.base.util.LifecycleWriteGuard;
import com.pms.common.exception.BusinessException;
import com.pms.common.security.UserContext;
import org.junit.jupiter.api.*;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import java.math.BigDecimal;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
/**
* 业主房间关联服务测试
*/
@DisplayName("业主房间关联服务测试")
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class OwnerRoomServiceImplTest {
private static final Long PROJECT_ID = 2001L;
private static final Long USER_ID = 1001L;
@Mock
private OwnerRoomMapper ownerRoomMapper;
@Mock
private OwnerMapper ownerMapper;
@Mock
private RoomMapper roomMapper;
@Mock
private LifecycleWriteGuard lifecycleWriteGuard;
@InjectMocks
private OwnerRoomServiceImpl ownerRoomService;
@BeforeEach
void setUp() {
UserContext.set(new UserContext.CurrentUser(USER_ID, "admin", PROJECT_ID, "ROLE_ADMIN", "1"));
}
@AfterEach
void tearDown() {
UserContext.clear();
}
@Test
@DisplayName("创建关联:业主不存在抛 OWNER_NOT_FOUND")
void create_ownerNotFound_throwsException() {
when(ownerMapper.selectById(999L)).thenReturn(null);
OwnerRoomRelRequest request = new OwnerRoomRelRequest();
request.setOwnerId(999L);
request.setRoomId(8001L);
assertThatThrownBy(() -> ownerRoomService.create(request))
.isInstanceOf(BusinessException.class);
verify(ownerRoomMapper, never()).insert(any());
}
@Test
@DisplayName("创建关联:房间不存在抛 ROOM_NOT_FOUND")
void create_roomNotFound_throwsException() {
Owner owner = new Owner();
owner.setId(9001L);
owner.setProjectId(PROJECT_ID);
when(ownerMapper.selectById(9001L)).thenReturn(owner);
when(roomMapper.selectById(999L)).thenReturn(null);
OwnerRoomRelRequest request = new OwnerRoomRelRequest();
request.setOwnerId(9001L);
request.setRoomId(999L);
assertThatThrownBy(() -> ownerRoomService.create(request))
.isInstanceOf(BusinessException.class);
verify(ownerRoomMapper, never()).insert(any());
}
@Test
@DisplayName("创建关联:已存在关联抛 RESOURCE_EXISTS")
void create_duplicateRelation_throwsException() {
Owner owner = new Owner();
owner.setId(9001L);
owner.setProjectId(PROJECT_ID);
Room room = new Room();
room.setId(8001L);
when(ownerMapper.selectById(9001L)).thenReturn(owner);
when(roomMapper.selectById(8001L)).thenReturn(room);
when(ownerRoomMapper.selectCount(any(LambdaQueryWrapper.class))).thenReturn(1L);
OwnerRoomRelRequest request = new OwnerRoomRelRequest();
request.setOwnerId(9001L);
request.setRoomId(8001L);
request.setRelationType(1);
assertThatThrownBy(() -> ownerRoomService.create(request))
.isInstanceOf(BusinessException.class)
.hasMessageContaining("业主房间关联已存在");
verify(ownerRoomMapper, never()).insert(any());
}
@Test
@DisplayName("创建关联:成功返回 IDshareRatio 默认 100.00")
void create_normal_returnsId() {
Owner owner = new Owner();
owner.setId(9001L);
owner.setProjectId(PROJECT_ID);
Room room = new Room();
room.setId(8001L);
when(ownerMapper.selectById(9001L)).thenReturn(owner);
when(roomMapper.selectById(8001L)).thenReturn(room);
when(ownerRoomMapper.selectCount(any(LambdaQueryWrapper.class))).thenReturn(0L);
when(ownerRoomMapper.insert(any(OwnerRoom.class))).thenAnswer(inv -> {
OwnerRoom rel = inv.getArgument(0);
rel.setId(7001L);
return 1;
});
OwnerRoomRelRequest request = new OwnerRoomRelRequest();
request.setOwnerId(9001L);
request.setRoomId(8001L);
request.setRelationType(1);
Long id = ownerRoomService.create(request);
assertThat(id).isEqualTo(7001L);
ArgumentCaptor<OwnerRoom> captor = ArgumentCaptor.forClass(OwnerRoom.class);
verify(ownerRoomMapper).insert(captor.capture());
OwnerRoom saved = captor.getValue();
assertThat(saved.getOwnerId()).isEqualTo(9001L);
assertThat(saved.getRoomId()).isEqualTo(8001L);
assertThat(saved.getShareRatio()).isEqualByComparingTo(new BigDecimal("100.00"));
assertThat(saved.getProjectId()).isEqualTo(PROJECT_ID);
assertThat(saved.getStatus()).isEqualTo(1);
assertThat(saved.getCreatedBy()).isEqualTo(USER_ID);
}
@Test
@DisplayName("删除关联:不存在抛异常")
void delete_notFound_throwsException() {
when(ownerRoomMapper.selectById(999L)).thenReturn(null);
assertThatThrownBy(() -> ownerRoomService.delete(999L))
.isInstanceOf(BusinessException.class);
verify(ownerRoomMapper, never()).deleteById(anyLong());
}
}

View File

@ -0,0 +1,158 @@
package com.pms.base.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.pms.base.dto.OwnerDTO;
import com.pms.base.dto.OwnerSaveRequest;
import com.pms.base.entity.Owner;
import com.pms.base.mapper.OwnerMapper;
import com.pms.base.service.impl.OwnerServiceImpl;
import com.pms.base.util.LifecycleWriteGuard;
import com.pms.common.exception.BusinessException;
import com.pms.common.response.PageResult;
import com.pms.common.security.UserContext;
import com.pms.common.util.CryptoUtil;
import org.junit.jupiter.api.*;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import java.util.Collections;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.*;
/**
* 业主服务测试含身份证/手机号加密验证
*/
@DisplayName("业主服务测试")
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class OwnerServiceImplTest {
private static final Long PROJECT_ID = 2001L;
private static final Long USER_ID = 1001L;
@Mock
private OwnerMapper ownerMapper;
@Mock
private CryptoUtil cryptoUtil;
@Mock
private LifecycleWriteGuard lifecycleWriteGuard;
@InjectMocks
private OwnerServiceImpl ownerService;
@BeforeEach
void setUp() {
UserContext.set(new UserContext.CurrentUser(USER_ID, "admin", PROJECT_ID, "ROLE_ADMIN", "1"));
}
@AfterEach
void tearDown() {
UserContext.clear();
}
@Test
@DisplayName("创建业主:身份证号/手机号加密并填充 hash")
void create_withIdNoAndPhone_encryptsFields() {
when(cryptoUtil.encrypt(anyString())).thenReturn("encrypted");
when(cryptoUtil.hash(anyString())).thenReturn("hash");
when(ownerMapper.selectCount(any(LambdaQueryWrapper.class))).thenReturn(0L);
when(ownerMapper.insert(any(Owner.class))).thenAnswer(inv -> {
Owner o = inv.getArgument(0);
o.setId(6001L);
return 1;
});
OwnerSaveRequest request = new OwnerSaveRequest();
request.setOwnerCode("O001");
request.setOwnerName("张三");
request.setIdNo("110101199001011234");
request.setPhone("13800001111");
request.setEmail("a@b.com");
request.setEmergencyPhone("13900002222");
Long id = ownerService.create(request);
assertThat(id).isEqualTo(6001L);
ArgumentCaptor<Owner> captor = ArgumentCaptor.forClass(Owner.class);
verify(ownerMapper).insert(captor.capture());
Owner saved = captor.getValue();
assertThat(saved.getProjectId()).isEqualTo(PROJECT_ID);
assertThat(saved.getOwnerCode()).isEqualTo("O001");
assertThat(saved.getIdNo()).isEqualTo("encrypted");
assertThat(saved.getIdNoHash()).isEqualTo("hash");
assertThat(saved.getPhone()).isEqualTo("encrypted");
assertThat(saved.getPhoneLast4Hash()).isEqualTo("hash");
assertThat(saved.getEmergencyPhone()).isEqualTo("encrypted");
assertThat(saved.getEmergencyPhoneHash()).isEqualTo("hash");
assertThat(saved.getCreatedBy()).isEqualTo(USER_ID);
}
@Test
@DisplayName("创建业主:编号重复抛 RESOURCE_EXISTS")
void create_duplicateCode_throwsException() {
when(ownerMapper.selectCount(any(LambdaQueryWrapper.class))).thenReturn(1L);
OwnerSaveRequest request = new OwnerSaveRequest();
request.setOwnerCode("O001");
request.setOwnerName("张三");
assertThatThrownBy(() -> ownerService.create(request))
.isInstanceOf(BusinessException.class)
.hasMessageContaining("业主编号已存在");
verify(ownerMapper, never()).insert(any());
}
@Test
@DisplayName("查询业主详情:解密身份证号/手机号")
void getById_decryptsSensitiveFields() {
OwnerDTO dto = new OwnerDTO();
dto.setId(6001L);
dto.setIdNo("enc-idno");
dto.setPhone("enc-phone");
dto.setEmail("enc-email");
dto.setEmergencyPhone("enc-emg");
when(ownerMapper.selectOwnerWithRooms(6001L)).thenReturn(dto);
when(ownerMapper.selectOwnerRooms(6001L)).thenReturn(Collections.emptyList());
when(cryptoUtil.decrypt(anyString())).thenReturn("decrypted");
OwnerDTO result = ownerService.getById(6001L);
assertThat(result.getIdNo()).isEqualTo("decrypted");
assertThat(result.getPhone()).isEqualTo("decrypted");
assertThat(result.getEmail()).isEqualTo("decrypted");
assertThat(result.getEmergencyPhone()).isEqualTo("decrypted");
assertThat(result.getRooms()).isEmpty();
}
@Test
@DisplayName("查询业主:不存在抛 OWNER_NOT_FOUND")
void getById_notFound_throwsException() {
when(ownerMapper.selectOwnerWithRooms(999L)).thenReturn(null);
assertThatThrownBy(() -> ownerService.getById(999L))
.isInstanceOf(BusinessException.class);
}
@Test
@DisplayName("删除业主:不存在抛 OWNER_NOT_FOUND")
void delete_notFound_throwsException() {
when(ownerMapper.selectById(999L)).thenReturn(null);
assertThatThrownBy(() -> ownerService.delete(999L))
.isInstanceOf(BusinessException.class);
verify(ownerMapper, never()).deleteById(anyLong());
}
}

View File

@ -12,6 +12,7 @@ import com.pms.base.service.impl.EnterpriseProfileServiceImpl;
import com.pms.base.service.impl.EnergyMeterServiceImpl;
import com.pms.base.service.impl.SafetyInspectionServiceImpl;
import com.pms.base.service.impl.WorkshopLeaseServiceImpl;
import com.pms.base.util.LifecycleWriteGuard;
import com.pms.common.exception.BusinessException;
import com.pms.common.security.UserContext;
import org.junit.jupiter.api.*;
@ -53,6 +54,9 @@ class ParkT2FlowTest {
@Mock
private EnterpriseProfileMapper enterpriseProfileMapper;
@Mock
private LifecycleWriteGuard lifecycleWriteGuard;
@InjectMocks
private WorkshopLeaseServiceImpl workshopLeaseService;
@ -91,7 +95,7 @@ class ParkT2FlowTest {
lease.setTenantEnterprise("科技股份公司");
lease.setLeaseStart(LocalDate.of(2026, 1, 1));
lease.setLeaseEnd(LocalDate.of(2027, 12, 31));
lease.setRentAmount(new BigDecimal("50000.00"));
lease.setRentAmountFen(5000000L);
when(workshopLeaseMapper.insert(any(WorkshopLease.class))).thenAnswer(inv -> {
WorkshopLease e = inv.getArgument(0);
@ -131,7 +135,7 @@ class ParkT2FlowTest {
lease.setId(5001L);
lease.setProjectId(2001L);
lease.setTenantEnterprise("科技股份公司");
lease.setRentAmount(new BigDecimal("50000.00"));
lease.setRentAmountFen(5000000L);
when(workshopLeaseMapper.selectById(5001L)).thenReturn(lease);
String billNo = workshopLeaseService.generateRentBill(5001L);

View File

@ -0,0 +1,132 @@
package com.pms.base.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.pms.base.constant.LifecycleStage;
import com.pms.base.dto.ProjectDTO;
import com.pms.base.dto.ProjectSaveRequest;
import com.pms.base.entity.Project;
import com.pms.base.mapper.ProjectMapper;
import com.pms.base.service.impl.ProjectServiceImpl;
import com.pms.common.exception.BusinessException;
import com.pms.common.response.PageResult;
import com.pms.common.security.UserContext;
import org.junit.jupiter.api.*;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import java.util.List;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.*;
/**
* 物业项目服务测试
*/
@DisplayName("物业项目服务测试")
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class ProjectServiceImplTest {
private static final Long USER_ID = 1001L;
@Mock
private ProjectMapper projectMapper;
@Mock
private RabbitTemplate rabbitTemplate;
@InjectMocks
private ProjectServiceImpl projectService;
@BeforeEach
void setUp() {
UserContext.set(new UserContext.CurrentUser(USER_ID, "admin", null, "ROLE_ADMIN", "1"));
}
@AfterEach
void tearDown() {
UserContext.clear();
}
@Test
@DisplayName("创建项目:编码重复抛 RESOURCE_EXISTS")
void create_duplicateCode_throwsException() {
when(projectMapper.selectCount(any(LambdaQueryWrapper.class))).thenReturn(1L);
ProjectSaveRequest request = new ProjectSaveRequest();
request.setProjectCode("P001");
request.setProjectName("阳光花园");
assertThatThrownBy(() -> projectService.create(request))
.isInstanceOf(BusinessException.class)
.hasMessageContaining("项目编码已存在");
verify(projectMapper, never()).insert(any());
}
@Test
@DisplayName("创建项目:成功返回 ID 并发送事件")
void create_normal_returnsIdAndSendsEvent() {
when(projectMapper.selectCount(any(LambdaQueryWrapper.class))).thenReturn(0L);
when(projectMapper.insert(any(Project.class))).thenAnswer(inv -> {
Project p = inv.getArgument(0);
p.setId(1001L);
return 1;
});
ProjectSaveRequest request = new ProjectSaveRequest();
request.setProjectCode("P001");
request.setProjectName("阳光花园");
Long id = projectService.create(request);
assertThat(id).isEqualTo(1001L);
ArgumentCaptor<Project> captor = ArgumentCaptor.forClass(Project.class);
verify(projectMapper).insert(captor.capture());
Project saved = captor.getValue();
assertThat(saved.getProjectCode()).isEqualTo("P001");
assertThat(saved.getLifecycleStage()).isEqualTo(LifecycleStage.INITIATION);
assertThat(saved.getCreatedBy()).isEqualTo(USER_ID);
verify(rabbitTemplate).convertAndSend(anyString(), anyString(), any(Object.class));
}
@Test
@DisplayName("查询项目:不存在抛 PROJECT_NOT_FOUND")
void getById_notFound_throwsException() {
when(projectMapper.selectProjectWithStats(999L)).thenReturn(null);
assertThatThrownBy(() -> projectService.getById(999L))
.isInstanceOf(BusinessException.class);
}
@Test
@DisplayName("更新项目:不存在抛 PROJECT_NOT_FOUND")
void update_notFound_throwsException() {
when(projectMapper.selectById(999L)).thenReturn(null);
assertThatThrownBy(() -> projectService.update(999L, new ProjectSaveRequest()))
.isInstanceOf(BusinessException.class);
verify(projectMapper, never()).updateById(any());
}
@Test
@DisplayName("删除项目:不存在抛 PROJECT_NOT_FOUND")
void delete_notFound_throwsException() {
when(projectMapper.selectById(999L)).thenReturn(null);
assertThatThrownBy(() -> projectService.delete(999L))
.isInstanceOf(BusinessException.class);
verify(projectMapper, never()).deleteById(anyLong());
}
}

View File

@ -15,6 +15,7 @@ import com.pms.base.service.impl.CommunityActivityServiceImpl;
import com.pms.base.service.impl.OwnerCommitteeServiceImpl;
import com.pms.base.service.impl.PublicRevenueServiceImpl;
import com.pms.base.service.impl.RenovationServiceImpl;
import com.pms.base.util.LifecycleWriteGuard;
import com.pms.common.exception.BusinessException;
import com.pms.common.security.UserContext;
import org.junit.jupiter.api.*;
@ -26,7 +27,6 @@ import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.Arrays;
import java.util.Collections;
@ -62,6 +62,9 @@ class ResidentialT2FlowTest {
@Mock
private LookupService lookupService;
@Mock
private LifecycleWriteGuard lifecycleWriteGuard;
@InjectMocks
private OwnerCommitteeServiceImpl ownerCommitteeService;
@InjectMocks
@ -165,7 +168,7 @@ class ResidentialT2FlowTest {
apply.setProjectId(PROJECT_ID);
apply.setRoomId(8001L);
apply.setOwnerId(9001L);
apply.setDepositAmount(new BigDecimal("2000.00"));
apply.setDepositAmountFen(200000L);
apply.setStartDate(LocalDate.of(2025, 7, 1));
apply.setEndDate(LocalDate.of(2025, 9, 1));
@ -439,7 +442,7 @@ class ResidentialT2FlowTest {
PublicRevenue request = new PublicRevenue();
request.setProjectId(PROJECT_ID);
request.setRevenueType("广告位");
request.setAmount(new BigDecimal("5000.00"));
request.setAmountFen(500000L);
request.setPeriod("2025-06");
request.setDescription("电梯广告");
@ -461,29 +464,29 @@ class ResidentialT2FlowTest {
assertThat(saved.getReceivedAt()).isNotNull();
// given 模拟月度汇总项目 2025-06 2 笔有效收益
PublicRevenue r1 = buildRevenue(8001L, "广告位", new BigDecimal("5000.00"), "2025-06", 1);
PublicRevenue r2 = buildRevenue(8002L, "场地租赁", new BigDecimal("3000.00"), "2025-06", 1);
PublicRevenue r1 = buildRevenue(8001L, "广告位", 500000L, "2025-06", 1);
PublicRevenue r2 = buildRevenue(8002L, "场地租赁", 300000L, "2025-06", 1);
when(publicRevenueMapper.selectList(any(LambdaQueryWrapper.class)))
.thenReturn(Arrays.asList(r1, r2));
// when
BigDecimal total = publicRevenueService.monthlySummary(PROJECT_ID, "2025-06");
Long total = publicRevenueService.monthlySummary(PROJECT_ID, "2025-06");
// then
assertThat(total).isEqualByComparingTo(new BigDecimal("8000.00"));
assertThat(total).isEqualTo(800000L);
}
@Test
@DisplayName("月度汇总:作废状态收益不计入总额")
void monthlySummary_excludesInvalidRecords() {
PublicRevenue valid = buildRevenue(8003L, "广告位", new BigDecimal("2000.00"), "2025-07", 1);
PublicRevenue invalid = buildRevenue(8004L, "场地租赁", new BigDecimal("9999.00"), "2025-07", 0);
PublicRevenue valid = buildRevenue(8003L, "广告位", 200000L, "2025-07", 1);
PublicRevenue invalid = buildRevenue(8004L, "场地租赁", 999900L, "2025-07", 0);
when(publicRevenueMapper.selectList(any(LambdaQueryWrapper.class)))
.thenReturn(List.of(valid)); // 模拟查询时只返回有效的
BigDecimal total = publicRevenueService.monthlySummary(PROJECT_ID, "2025-07");
Long total = publicRevenueService.monthlySummary(PROJECT_ID, "2025-07");
assertThat(total).isEqualByComparingTo(new BigDecimal("2000.00"));
assertThat(total).isEqualTo(200000L);
}
@Test
@ -492,17 +495,17 @@ class ResidentialT2FlowTest {
when(publicRevenueMapper.selectList(any(LambdaQueryWrapper.class)))
.thenReturn(Collections.emptyList());
BigDecimal total = publicRevenueService.monthlySummary(PROJECT_ID, "2025-08");
Long total = publicRevenueService.monthlySummary(PROJECT_ID, "2025-08");
assertThat(total).isEqualByComparingTo(BigDecimal.ZERO);
assertThat(total).isEqualTo(0L);
}
@Test
@DisplayName("月度汇总projectId 为 null 返回 0")
void monthlySummary_nullProjectId_returnsZero() {
BigDecimal total = publicRevenueService.monthlySummary(null, "2025-06");
Long total = publicRevenueService.monthlySummary(null, "2025-06");
assertThat(total).isEqualByComparingTo(BigDecimal.ZERO);
assertThat(total).isEqualTo(0L);
verify(publicRevenueMapper, never()).selectList(any());
}
@ -512,7 +515,7 @@ class ResidentialT2FlowTest {
PublicRevenue request = new PublicRevenue();
request.setProjectId(PROJECT_ID);
request.setRevenueType("广告位");
request.setAmount(new BigDecimal("1000.00"));
request.setAmountFen(100000L);
request.setPeriod(null);
assertThatThrownBy(() -> publicRevenueService.create(request))
@ -555,12 +558,12 @@ class ResidentialT2FlowTest {
return activity;
}
private PublicRevenue buildRevenue(Long id, String type, BigDecimal amount, String period, int status) {
private PublicRevenue buildRevenue(Long id, String type, Long amountFen, String period, int status) {
PublicRevenue revenue = new PublicRevenue();
revenue.setId(id);
revenue.setProjectId(PROJECT_ID);
revenue.setRevenueType(type);
revenue.setAmount(amount);
revenue.setAmountFen(amountFen);
revenue.setPeriod(period);
revenue.setStatus(status);
return revenue;

View File

@ -0,0 +1,159 @@
package com.pms.base.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.pms.base.dto.RoomSaveRequest;
import com.pms.base.entity.Building;
import com.pms.base.entity.Floor;
import com.pms.base.entity.Room;
import com.pms.base.mapper.BuildingMapper;
import com.pms.base.mapper.FloorMapper;
import com.pms.base.mapper.OwnerRoomMapper;
import com.pms.base.mapper.RoomMapper;
import com.pms.base.service.impl.RoomServiceImpl;
import com.pms.base.util.LifecycleWriteGuard;
import com.pms.common.exception.BusinessException;
import com.pms.common.security.UserContext;
import org.junit.jupiter.api.*;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
/**
* 房间服务测试
*/
@DisplayName("房间服务测试")
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class RoomServiceImplTest {
private static final Long PROJECT_ID = 2001L;
private static final Long USER_ID = 1001L;
@Mock
private RoomMapper roomMapper;
@Mock
private BuildingMapper buildingMapper;
@Mock
private FloorMapper floorMapper;
@Mock
private OwnerRoomMapper ownerRoomMapper;
@Mock
private LifecycleWriteGuard lifecycleWriteGuard;
@InjectMocks
private RoomServiceImpl roomService;
@BeforeEach
void setUp() {
UserContext.set(new UserContext.CurrentUser(USER_ID, "admin", PROJECT_ID, "ROLE_ADMIN", "1"));
}
@AfterEach
void tearDown() {
UserContext.clear();
}
@Test
@DisplayName("创建房间:楼栋不存在抛 BUILDING_NOT_FOUND")
void create_buildingNotFound_throwsException() {
when(buildingMapper.selectById(999L)).thenReturn(null);
RoomSaveRequest request = new RoomSaveRequest();
request.setBuildingId(999L);
request.setFloorId(10L);
request.setRoomCode("R001");
assertThatThrownBy(() -> roomService.create(request))
.isInstanceOf(BusinessException.class);
verify(roomMapper, never()).insert(any());
}
@Test
@DisplayName("创建房间:房间编码重复抛 RESOURCE_EXISTS")
void create_duplicateCode_throwsException() {
Building building = new Building();
building.setId(100L);
building.setProjectId(PROJECT_ID);
Floor floor = new Floor();
floor.setId(10L);
when(buildingMapper.selectById(100L)).thenReturn(building);
when(floorMapper.selectById(10L)).thenReturn(floor);
when(roomMapper.selectCount(any(LambdaQueryWrapper.class))).thenReturn(1L);
RoomSaveRequest request = new RoomSaveRequest();
request.setBuildingId(100L);
request.setFloorId(10L);
request.setRoomCode("R001");
assertThatThrownBy(() -> roomService.create(request))
.isInstanceOf(BusinessException.class)
.hasMessageContaining("房间编码已存在");
verify(roomMapper, never()).insert(any());
}
@Test
@DisplayName("创建房间:成功返回 ID 且 roomName 默认取 roomCode")
void create_normal_returnsId() {
Building building = new Building();
building.setId(100L);
building.setProjectId(PROJECT_ID);
Floor floor = new Floor();
floor.setId(10L);
when(buildingMapper.selectById(100L)).thenReturn(building);
when(floorMapper.selectById(10L)).thenReturn(floor);
when(roomMapper.selectCount(any(LambdaQueryWrapper.class))).thenReturn(0L);
when(roomMapper.insert(any(Room.class))).thenAnswer(inv -> {
Room r = inv.getArgument(0);
r.setId(8001L);
return 1;
});
RoomSaveRequest request = new RoomSaveRequest();
request.setBuildingId(100L);
request.setFloorId(10L);
request.setRoomCode("R001");
Long id = roomService.create(request);
assertThat(id).isEqualTo(8001L);
ArgumentCaptor<Room> captor = ArgumentCaptor.forClass(Room.class);
verify(roomMapper).insert(captor.capture());
Room saved = captor.getValue();
assertThat(saved.getRoomCode()).isEqualTo("R001");
assertThat(saved.getRoomName()).isEqualTo("R001");
assertThat(saved.getProjectId()).isEqualTo(PROJECT_ID);
assertThat(saved.getCreatedBy()).isEqualTo(USER_ID);
}
@Test
@DisplayName("更新房间:不存在抛 ROOM_NOT_FOUND")
void update_notFound_throwsException() {
when(roomMapper.selectById(999L)).thenReturn(null);
assertThatThrownBy(() -> roomService.update(999L, new RoomSaveRequest()))
.isInstanceOf(BusinessException.class);
verify(roomMapper, never()).updateById(any());
}
@Test
@DisplayName("删除房间:不存在抛 ROOM_NOT_FOUND")
void delete_notFound_throwsException() {
when(roomMapper.selectById(999L)).thenReturn(null);
assertThatThrownBy(() -> roomService.delete(999L))
.isInstanceOf(BusinessException.class);
verify(roomMapper, never()).deleteById(anyLong());
}
}

View File

@ -0,0 +1,166 @@
package com.pms.base.service;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.pms.base.dto.TenantDTO;
import com.pms.base.dto.TenantSaveRequest;
import com.pms.base.entity.Tenant;
import com.pms.base.mapper.TenantMapper;
import com.pms.base.service.impl.TenantServiceImpl;
import com.pms.base.util.LifecycleWriteGuard;
import com.pms.common.exception.BusinessException;
import com.pms.common.response.PageResult;
import com.pms.common.security.UserContext;
import com.pms.common.util.CryptoUtil;
import org.junit.jupiter.api.*;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import java.util.Collections;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.*;
/**
* 租户服务测试含敏感字段加密验证
*/
@DisplayName("租户服务测试")
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
class TenantServiceImplTest {
private static final Long PROJECT_ID = 2001L;
private static final Long USER_ID = 1001L;
@Mock
private TenantMapper tenantMapper;
@Mock
private CryptoUtil cryptoUtil;
@Mock
private LifecycleWriteGuard lifecycleWriteGuard;
@InjectMocks
private TenantServiceImpl tenantService;
@BeforeEach
void setUp() {
UserContext.set(new UserContext.CurrentUser(USER_ID, "admin", PROJECT_ID, "ROLE_ADMIN", "1"));
}
@AfterEach
void tearDown() {
UserContext.clear();
}
@Test
@DisplayName("创建租户:敏感字段加密并填充 hash")
void create_withSensitiveFields_encryptsAndFillsHashes() {
when(cryptoUtil.encrypt(anyString())).thenReturn("encrypted");
when(cryptoUtil.hash(anyString())).thenReturn("hash");
when(tenantMapper.insert(any(Tenant.class))).thenAnswer(inv -> {
Tenant t = inv.getArgument(0);
t.setId(5001L);
return 1;
});
TenantSaveRequest request = new TenantSaveRequest();
request.setTenantName("租户A");
request.setIdNo("110101199001011234");
request.setPhone("13800001111");
request.setEmail("a@b.com");
Long id = tenantService.create(request);
assertThat(id).isEqualTo(5001L);
ArgumentCaptor<Tenant> captor = ArgumentCaptor.forClass(Tenant.class);
verify(tenantMapper).insert(captor.capture());
Tenant saved = captor.getValue();
assertThat(saved.getProjectId()).isEqualTo(PROJECT_ID);
assertThat(saved.getTenantName()).isEqualTo("租户A");
// 加密字段填充
assertThat(saved.getIdNo()).isEqualTo("encrypted");
assertThat(saved.getIdNoHash()).isEqualTo("hash");
assertThat(saved.getPhone()).isEqualTo("encrypted");
assertThat(saved.getPhoneHash()).isEqualTo("hash");
assertThat(saved.getPhoneLast4Hash()).isEqualTo("hash");
assertThat(saved.getEmail()).isEqualTo("encrypted");
assertThat(saved.getEmailHash()).isEqualTo("hash");
assertThat(saved.getCreatedBy()).isEqualTo(USER_ID);
assertThat(saved.getStatus()).isEqualTo(1);
}
@Test
@DisplayName("创建租户:无敏感字段时不触发加密")
void create_withoutSensitiveFields_noEncryption() {
when(tenantMapper.insert(any(Tenant.class))).thenAnswer(inv -> {
Tenant t = inv.getArgument(0);
t.setId(5002L);
return 1;
});
TenantSaveRequest request = new TenantSaveRequest();
request.setTenantName("租户B");
Long id = tenantService.create(request);
assertThat(id).isEqualTo(5002L);
verify(cryptoUtil, never()).encrypt(anyString());
verify(cryptoUtil, never()).hash(anyString());
}
@Test
@DisplayName("更新租户:不存在抛 RESOURCE_NOT_FOUND")
void update_notFound_throwsException() {
when(tenantMapper.selectById(999L)).thenReturn(null);
assertThatThrownBy(() -> tenantService.update(999L, new TenantSaveRequest()))
.isInstanceOf(BusinessException.class);
verify(tenantMapper, never()).updateById(any());
}
@Test
@DisplayName("删除租户:不存在抛 RESOURCE_NOT_FOUND")
void delete_notFound_throwsException() {
when(tenantMapper.selectById(999L)).thenReturn(null);
assertThatThrownBy(() -> tenantService.delete(999L))
.isInstanceOf(BusinessException.class);
verify(tenantMapper, never()).deleteById(anyLong());
}
@Test
@DisplayName("分页查询返回结果")
void page_normal_returnsResult() {
TenantDTO query = new TenantDTO();
query.setPage(1);
query.setSize(10);
TenantDTO dto = new TenantDTO();
dto.setId(1L);
dto.setTenantName("租户A");
Page<TenantDTO> mockPage = new Page<>(1, 10);
mockPage.setRecords(java.util.List.of(dto));
mockPage.setTotal(1);
when(tenantMapper.selectTenantPage(any(Page.class), any(), any(), any()))
.thenReturn(mockPage);
PageResult<TenantDTO> result = tenantService.page(query);
assertThat(result.getList()).hasSize(1);
assertThat(result.getTotal()).isEqualTo(1);
assertThat(result.getPage()).isEqualTo(1);
assertThat(result.getSize()).isEqualTo(10);
}
}

View File

@ -0,0 +1,245 @@
package com.pms.base.util;
import com.pms.common.exception.BusinessException;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import java.util.HashSet;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* AES-256-GCM 加密工具测试U1
*
* <p>验证GCM 加解密随机 IVHMAC-SHA256 hash异常抛出密钥未配置启动失败
*/
@DisplayName("AesEncryptUtil AES-256-GCM 测试")
class AesEncryptUtilTest {
private AesEncryptUtil util;
@BeforeEach
void setUp() {
util = new AesEncryptUtil("0123456789abcdef0123456789abcdef");
}
@Nested
@DisplayName("加密测试")
class EncryptTest {
@Test
@DisplayName("加密明文返回 Base64 密文")
void encrypt_normal_returnsBase64() {
String cipher = util.encrypt("110101199001011234");
assertThat(cipher).isNotNull().isNotEmpty();
// Base64 解码后长度 > IV(12) + tag(16)
assertThat(cipher.length()).isGreaterThan(40);
}
@Test
@DisplayName("相同明文两次加密结果不同IV 随机)")
void encrypt_samePlaintext_differentResult() {
String c1 = util.encrypt("敏感数据");
String c2 = util.encrypt("敏感数据");
assertThat(c1).isNotEqualTo(c2);
}
@Test
@DisplayName("空字符串加密返回 null")
void encrypt_empty_returnsNull() {
assertThat(util.encrypt("")).isNull();
}
@Test
@DisplayName("null 加密返回 null")
void encrypt_null_returnsNull() {
assertThat(util.encrypt(null)).isNull();
}
}
@Nested
@DisplayName("解密测试")
class DecryptTest {
@Test
@DisplayName("解密密文还原明文")
void decrypt_normal_returnsPlaintext() {
String plain = "110101199001011234";
String decrypted = util.decrypt(util.encrypt(plain));
assertThat(decrypted).isEqualTo(plain);
}
@Test
@DisplayName("空字符串解密返回 null")
void decrypt_empty_returnsNull() {
assertThat(util.decrypt("")).isNull();
}
@Test
@DisplayName("null 解密返回 null")
void decrypt_null_returnsNull() {
assertThat(util.decrypt(null)).isNull();
}
@Test
@DisplayName("篡改密文解密抛 BusinessExceptionGCM 完整性校验)")
void decrypt_tampered_throwsBusinessException() {
String cipher = util.encrypt("原始数据");
// 篡改最后 2 字符
String tampered = cipher.substring(0, cipher.length() - 2) + "XX";
assertThatThrownBy(() -> util.decrypt(tampered))
.isInstanceOf(BusinessException.class)
.hasMessageContaining("AES解密失败");
}
@Test
@DisplayName("非 Base64 字符串解密抛 BusinessException")
void decrypt_invalidBase64_throwsBusinessException() {
assertThatThrownBy(() -> util.decrypt("这不是Base64!!!"))
.isInstanceOf(BusinessException.class);
}
@Test
@DisplayName("密文长度不足 IV 长度抛 BusinessException")
void decrypt_tooShort_throwsBusinessException() {
// 5 字节 Base64解码后 < 12 字节 IV
assertThatThrownBy(() -> util.decrypt("YWJjZA=="))
.isInstanceOf(BusinessException.class);
}
}
@Nested
@DisplayName("往返测试")
class RoundTripTest {
@Test
@DisplayName("加解密往返一致")
void roundTrip_success() {
String[] cases = {
"短文本",
"110101199001011234",
"{\"idCard\":\"110101199001011234\",\"phone\":\"13800138000\"}",
"这是一段中等长度的敏感数据包含中英混合内容ABC123"
};
for (String plain : cases) {
assertThat(util.decrypt(util.encrypt(plain))).isEqualTo(plain);
}
}
@Test
@DisplayName("长文本加解密往返")
void roundTrip_longText() {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 500; i++) sb.append("").append(i);
String longText = sb.toString();
assertThat(util.decrypt(util.encrypt(longText))).isEqualTo(longText);
}
@Test
@DisplayName("Emoji 和 Unicode 加解密往返")
void roundTrip_specialChars() {
String[] cases = {"Emoji: 😀🎉🔐", "Unicode: \u00e9\u00e8\u00ea", "特殊:!@#$%^&*()"};
for (String plain : cases) {
assertThat(util.decrypt(util.encrypt(plain))).isEqualTo(plain);
}
}
}
@Nested
@DisplayName("HMAC-SHA256 哈希测试")
class HashTest {
@Test
@DisplayName("哈希返回 64 位十六进制字符串")
void hash_normal_returnsHex() {
String hash = util.hash("110101199001011234");
assertThat(hash).isNotNull().hasSize(64).matches("^[0-9a-f]{64}$");
}
@Test
@DisplayName("相同明文哈希一致")
void hash_sameInput_sameResult() {
assertThat(util.hash("测试")).isEqualTo(util.hash("测试"));
}
@Test
@DisplayName("不同明文哈希不同")
void hash_differentInput_differentResult() {
assertThat(util.hash("数据1")).isNotEqualTo(util.hash("数据2"));
}
@Test
@DisplayName("空字符串哈希返回 null")
void hash_empty_returnsNull() {
assertThat(util.hash("")).isNull();
}
@Test
@DisplayName("null 哈希返回 null")
void hash_null_returnsNull() {
assertThat(util.hash(null)).isNull();
}
@Test
@DisplayName("大量输入哈希无碰撞")
void hash_manyInputs_noCollision() {
Set<String> hashes = new HashSet<>();
for (int i = 0; i < 5000; i++) hashes.add(util.hash("data_" + i));
assertThat(hashes).hasSize(5000);
}
@Test
@DisplayName("HMAC 哈希与不同密钥实例结果不同(密钥绑定)")
void hash_differentKey_differentResult() {
AesEncryptUtil other = new AesEncryptUtil("different_key_0123456789abcdef");
assertThat(util.hash("same_input")).isNotEqualTo(other.hash("same_input"));
}
}
@Nested
@DisplayName("密钥初始化测试")
class InitTest {
@Test
@DisplayName("密钥为 null 启动失败")
void init_nullKey_throws() {
assertThatThrownBy(() -> new AesEncryptUtil(null))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("未配置");
}
@Test
@DisplayName("密钥为空字符串启动失败")
void init_blankKey_throws() {
assertThatThrownBy(() -> new AesEncryptUtil(" "))
.isInstanceOf(IllegalStateException.class);
}
@Test
@DisplayName("短密钥通过 SHA-256 派生后正常工作")
void init_shortKey_works() {
AesEncryptUtil shortKeyUtil = new AesEncryptUtil("short");
String plain = "测试短密钥";
assertThat(shortKeyUtil.decrypt(shortKeyUtil.encrypt(plain))).isEqualTo(plain);
}
}
@Nested
@DisplayName("IV 随机性测试CSPRNG 验证)")
class IvRandomnessTest {
@Test
@DisplayName("1000 次加密相同明文,密文全不同")
void encrypt_1000Times_allDifferent() {
Set<String> ciphers = new HashSet<>();
for (int i = 0; i < 1000; i++) {
ciphers.add(util.encrypt("same_plaintext"));
}
assertThat(ciphers).hasSize(1000);
}
}
}

View File

@ -0,0 +1,97 @@
package com.pms.base.util;
import org.junit.jupiter.api.Test;
import javax.crypto.Cipher;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.SecureRandom;
import java.util.Arrays;
import static org.junit.jupiter.api.Assertions.*;
/**
* POC: 验证 JDK 原生 GCM 支持
* Hutool 5.8.27 Mode 枚举无 GCM改用 JDK 原生 Cipher
*/
class GcmPocTest {
private static final String KEY = "EtherPMS2024SecureKey!!"; // 原始 key SHA-256 派生 32 字节
private static final String PLAINTEXT = "110101199001011234";
private static byte[] deriveKey(String key) {
try {
return java.security.MessageDigest.getInstance("SHA-256")
.digest(key.getBytes(StandardCharsets.UTF_8));
} catch (Exception e) {
throw new RuntimeException(e);
}
}
@Test
void jdkNativeGcm_poc() throws Exception {
// JDK 原生 GCM 作为备选方案
byte[] keyBytes = deriveKey(KEY);
SecretKeySpec secretKey = new SecretKeySpec(keyBytes, "AES");
// 生成随机 IV (12 bytes, GCM 标准)
byte[] iv = new byte[12];
new SecureRandom().nextBytes(iv);
// 加密
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
GCMParameterSpec parameterSpec = new GCMParameterSpec(128, iv); // 128-bit auth tag
cipher.init(Cipher.ENCRYPT_MODE, secretKey, parameterSpec);
byte[] cipherText = cipher.doFinal(PLAINTEXT.getBytes(StandardCharsets.UTF_8));
// IV + cipherText 拼接存储
byte[] combined = new byte[iv.length + cipherText.length];
System.arraycopy(iv, 0, combined, 0, iv.length);
System.arraycopy(cipherText, 0, combined, iv.length, cipherText.length);
assertNotNull(combined);
assertTrue(combined.length > iv.length, "Cipher text should be longer than IV");
// 解密
byte[] extractedIv = Arrays.copyOfRange(combined, 0, 12);
byte[] extractedCipherText = Arrays.copyOfRange(combined, 12, combined.length);
Cipher decryptCipher = Cipher.getInstance("AES/GCM/NoPadding");
decryptCipher.init(Cipher.DECRYPT_MODE, secretKey, new GCMParameterSpec(128, extractedIv));
byte[] decrypted = decryptCipher.doFinal(extractedCipherText);
assertEquals(PLAINTEXT, new String(decrypted, StandardCharsets.UTF_8));
System.out.println("[JDK GCM] PASS - combined length: " + combined.length +
" (IV=12 + ciphertext=" + (combined.length - 12 - 16) + " + tag=16)");
}
@Test
void jdkGcm_randomIvEachEncrypt() throws Exception {
// 验证每次加密 IV 不同 密文不同
byte[] keyBytes = deriveKey(KEY);
SecretKeySpec secretKey = new SecretKeySpec(keyBytes, "AES");
SecureRandom secureRandom = new SecureRandom();
String enc1 = jdkGcmEncrypt(secretKey, PLAINTEXT, secureRandom);
String enc2 = jdkGcmEncrypt(secretKey, PLAINTEXT, secureRandom);
assertNotEquals(enc1, enc2, "Same plaintext should produce different ciphertext due to random IV");
System.out.println("[JDK GCM] Random IV PASS - enc1 != enc2");
}
private String jdkGcmEncrypt(SecretKeySpec secretKey, String plaintext, SecureRandom random) throws Exception {
byte[] iv = new byte[12];
random.nextBytes(iv);
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.ENCRYPT_MODE, secretKey, new GCMParameterSpec(128, iv));
byte[] cipherText = cipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8));
byte[] combined = new byte[iv.length + cipherText.length];
System.arraycopy(iv, 0, combined, 0, iv.length);
System.arraycopy(cipherText, 0, combined, iv.length, cipherText.length);
return java.util.Base64.getEncoder().encodeToString(combined);
}
}

View File

@ -0,0 +1,691 @@
---
date: 2026-07-02
type: fix
origin: pms-base 模块架构评审2026-07-02 会话评审报告)
deepened: 2026-07-02
revised: 2026-07-02
---
# pms-base 基础数据模块加固修复计划
## Summary
针对 pms-base 模块架构评审发现的问题,分阶段系统性修复 AES 加密漏洞ECB→GCM 全量迁移、BigDecimal 金额字段、敏感字段加密、审计日志、测试覆盖率、入参校验、Feign 熔断等。
**鉴权相关工作ProjectSecurityChecker 补齐、LifecycleWriteGuard null skip 修复、@PreAuthorize 标注)全部延后**至 `feat/user-org-perm` worktree 合并后再统一处理,避免重复改造。本计划聚焦 pms-base 自身代码加固,不依赖也不阻塞另一个会话。
**Origin:** pms-base 模块评审报告2026-07-02 会话,三维度并行评审:安全与权限 / 架构与代码规范 / 测试与数据库迁移)
---
## Problem Frame
pms-base 模块作为 EtherPMS 基础数据服务,承载业主、租户、合同、设备、能源表、装修、公共收益等核心业务实体。架构评审发现问题跨 P0-P3 严重级别,覆盖以下根因:
1. **安全基础设施空转**AesEncryptUtil 注释声称 AES-256-GCM 实际使用 ECB 模式Hutool 默认AES key 硬编码 `EtherPMS2024SecureKey!!` 作为默认值且 yml 配置键名不一致(`app.encrypt.aes-key` vs `crypto.aes.key`idNoHash 字段定义但从未填充;手机号/邮箱明文存储与 idNo 加密策略不一致。
2. **跨项目越权**(延后至 feat/user-org-perm 合并30/31 Controller 零 @PreAuthorize19+/30+ ServiceImpl 缺失 ProjectSecurityCheckerLifecycleWriteGuard 在 projectId=null 时静默放行。
3. **审计合规缺失**:仅 3/31 Controller 使用 @AuditLog,业务操作不可追溯,违反《网络安全法》第 21 条日志留存要求。
4. **数据规范偏离**14 个 entity 使用 BigDecimal 存金额,违反 CLAUDE.md "金额数据统一使用 Long 类型的 priceFen"约定。
5. **代码质量技术债**@Lazy 循环依赖、Feign 无熔断、3 个 delete 方法缺 @Transactional、入参校验缺失、测试覆盖率不足13/32 ServiceImpl 零测试)。
---
## Requirements
### 功能性需求
- **R1P0**AES 加密采用 GCM 模式AES key 从 Nacos 配置中心加载禁止硬编码默认值idNoHash 字段在加密时同步填充HMAC-SHA256IV 使用 `SecureRandom`CSPRNG每记录独立生成提供历史 ECB 数据停机迁移脚本。
- **R2延后**~~所有业务 ServiceImpl 补齐 ProjectSecurityChecker 跨项目校验LifecycleWriteGuard 在 projectId=null 时抛异常而非静默放行。~~ **延后至 feat/user-org-perm 合并后统一用 @PreAuthorize 处理。**
- **R3P1**:所有业务 Controller 写操作create/update/delete/状态流转)标注 @AuditLog,记录操作人、操作类型、目标实体。涉及 PII 的方法禁用 `recordParams`,关键操作显式标记非敏感字段。
- **R4P1**14 个 entity 的金额字段从 BigDecimal 迁移到 LongDDL 同步变更DECIMAL→BIGINTDTO 使用 `xxxPriceFen` 字段名。
- **R5P1**Owner/Tenant/Enterprise 实体的 phone/email 字段采用 hash + 加密字段模式存储HMAC-SHA256 hash + GCM 加密),支持 hash 精确查询 + 后4位 hash 索引(尾号查询)。
- **R6P1**13 个零测试 ServiceImpl 补齐单元测试,覆盖加密/金额计算等高风险逻辑。
- **R7P2**11 个 Controller 的 Entity @RequestBody@Valid + 字段级 @NotBlank/@Pattern 校验。
- **R8P2**Feign 客户端配置 Resilience4j 熔断AuthClient fallback 抛 BusinessException 而非返回 null。
- **R9P3**3 个 delete 方法补 @TransactionalAesEncryptUtil 加解密异常抛出而非吞掉。
- **R10P3**@Lazy 循环依赖LifecycleService ↔ ArchiveService文档化为已知设计标注于 CONCEPTS.md。
- **R11延后**~~业务 Controller 标注 @PreAuthorize~~ **延后至 feat/user-org-perm 合并。**
### 非功能性需求
- **N1**:历史数据迁移必须可重入、可回滚,迁移期间双读兼容(如需灰度)。
- **N2**P0 修复须在 1 周内上线P1 在 2 周内完成。
- **N3**:所有修复不破坏现有 80 个单元测试。
- **N4**:迁移脚本与代码变更同 PR 提交,禁止 schema 与代码分批。
### 成功标准
- SC1AesEncryptUtil 使用 GCM 模式IV 用 SecureRandom 生成key 从 Nacos 加载,无默认值回退。
- ~~SC2所有 ServiceImpl 写操作经过 ProjectSecurityChecker 校验。~~ **延后。**
- SC3所有业务 Controller 写操作有 @AuditLog 注解,涉及 PII 的方法 `recordParams=false`
- SC4金额字段全链路使用 LongDTO 字段命名 `xxxPriceFen`,无 BigDecimal 残留。
- SC5phone/email 字段加密存储hash 精确查询 + 后4位尾号查询可用API 响应可解密展示。
- SC6单元测试覆盖率 ≥ 60%ServiceImpl 数维度)。
---
## Key Technical Decisions
### KD1. AES 加密重构GCM 模式 + Nacos key + HMAC-SHA256 hash + SecureRandom IV + 停机迁移
**决策:** 采用 AES-256-GCM 模式key 从 Nacos 配置中心加载(`app.encrypt.aes-key`移除默认值启动时若未配置则抛异常idNoHash 在 encrypt 时同步填充HMAC-SHA256 with server secretIV 使用 `java.security.SecureRandom`CSPRNG每记录独立生成与密文一同存储历史 ECB 数据停机迁移。
**前置闸门:** Hutool GCM 支持 POC2-4 小时)。若 Hutool `cn.hutool.crypto.symmetric.AES` 不支持 GCM则切 JDK 原生 `Cipher.getInstance("AES/GCM/NoPadding")` 或 BouncyCastle。POC 失败才降级为最低修复(仅修配置+idNoHash+异常,保留 ECB
| 优势 | 劣势 |
|------|------|
| 根治 ECB 模式分析攻击风险 | 历史数据迁移需停机窗口idNo 数据量小,约 30 分钟) |
| GCM 提供保密性 + 完整性AEAD | key 切换需协调所有环境 Nacos 配置 |
| HMAC-SHA256 抗彩虹表 | server secret 泄露后 hash 失效 |
| 合规《个人信息保护法》第 51 条 | 解密异常不再吞掉,上层需处理 |
**替代方案考虑:**
- 仅最低修复(修配置+idNoHash+异常,保留 ECB省事但后续手机号/邮箱加密仍用 ECBGCM 迁移时需二次迁移所有加密数据(含新增的 phone/email总成本更高。
- 双读灰度迁移:无停机但解密需探测 ECB/GCM 模式复杂度高idNo 数据量小不值得。
**理由:** 从未来安全可用角度GCM 一次到位避免双倍迁移成本。用户确认 GCM 全量迁移 + 停机窗口。
### KD2. BigDecimal→Long 全链路迁移
**决策:** 14 个 entity 的 BigDecimal 金额字段改为 LongFlyway V13 迁移脚本将 DECIMAL(15,2) 转换为 BIGINT× 100DTO 字段统一命名 `xxxPriceFen`(与 CLAUDE.md 一致Service 层金额计算使用 Long乘除用 BigDecimal 中间计算后 `setScale(0, RoundingMode.HALF_UP).longValueExact()` 转回 Long。
| 优势 | 劣势 |
|------|------|
| 与 CLAUDE.md 约定一致 | 14 表 schema 变更,需停机或灰度 |
| 与 pms-charge 金额单位统一 | 前端 DTO 字段名变更需协调 |
| 消除 BigDecimal 比较陷阱 | 历史数据如有小数需四舍五入 |
**理由:** 用户确认全链路迁移CLAUDE.md 明确要求;与 pms-charge 已用 Long 保持一致。字段命名 `xxxPriceFen` 遵循 CLAUDE.md 约定。
### KD3. 手机号/邮箱加密HMAC-SHA256 hash + GCM 加密 + 后4位 hash 索引
**决策:** Owner/Tenant/Enterprise 实体的 phone/email 字段拆分为 `phone`GCM 加密存储)+ `phoneHash`HMAC-SHA256 用于精确查询)+ `phoneLast4Hash`HMAC-SHA256 of 后4位用于尾号查询email 同理。Project 实体无 phone/email 字段,不涉及。查询时用 hash 精确匹配或后4位 hash 尾号查询展示时解密。Flyway V14 迁移脚本对历史明文数据批量加密 + 填充 hash。
| 优势 | 劣势 |
|------|------|
| 与 idNo 加密策略一致 | like 前缀模糊查询失效(保留尾号查询) |
| 数据库泄露不暴露明文 | 改造面大,涉及查询逻辑 |
| 后4位 hash 支持客服尾号查询 | API 响应需统一解密处理 |
| 合规《个人信息保护法》最小化原则 | 需产品确认客服实际查询模式 |
**替代方案考虑:**
- 仅 API 响应脱敏:存储仍明文,未根治。
- 手机号不加密仅脱敏:保留 LIKE 能力但手机号也是 PII。
**理由:** 用户确认 hash + 加密字段模式 + 后4位 hash 索引。Project.java 无 phone/email 字段,排除。
### KD4. 分阶段交付P0 先行 + P1 加固 + 架构优化 + 鉴权延后
**决策:** 计划分三个阶段(鉴权延后不在本计划):
- 阶段 1P0 紧急U1 AES GCM 全量迁移
- 阶段 2P1 加固U2 @AuditLog + U3 BigDecimal 迁移 + U4 手机邮箱加密 + U5 入参校验 + U6 测试覆盖
- 阶段 3架构 + P3U7 Feign 熔断 + U8 @Transactional + U9 @Lazy 文档化
| 优势 | 劣势 |
|------|------|
| P0 快速上线降风险 | 阶段间需协调(如 U4 等 U1 完成) |
| P1 批量加固效率高 | 多 PR review 成本 |
| 鉴权延后不阻塞外部会话 | P0 越权漏洞持续暴露直至合并 |
**理由:** 用户确认分阶段;鉴权全部延后至 feat/user-org-perm 合并。已知风险P0 越权漏洞LifecycleWriteGuard null skip + Controller 零鉴权)持续暴露,文档化为已知技术债。
### KD5. @AuditLog 现在标注,冲突时对齐
**决策:** 现在基于现有 @AuditLog AOP 切面批量标注业务 Controller使用实际注解属性 `module`/`type`/`description`/`recordParams`/`recordResult`(非 `action`/`target`feat/user-org-perm 合并后若字段规范增强,统一对齐。
| 优势 | 劣势 |
|------|------|
| 不阻塞 P1 修复 | 可能返工(字段冲突) |
| AOP 切面已工作 | — |
**理由:** 用户确认现在标注;@AuditLog 切面已工作,字段冲突可后续对齐。
### KD6. @AuditLog PII 脱敏A+C 混合
**决策:** 涉及 PIIidNo/phone/email的方法默认 `recordParams=false`;关键操作(如状态流转)显式标记非敏感字段记录。避免 AOP 序列化请求体时把明文 PII 写入 t_audit_log。
| 优势 | 劣势 |
|------|------|
| 防止审计日志泄露 PII | 涉及 PII 的操作失去参数审计 |
| 简单,无需额外脱敏框架 | 需逐方法判断是否涉及 PII |
**理由:** 用户确认 A+C 混合方案。违反《个人信息保护法》第 51 条的风险高于审计价值损失。
### KD7. 鉴权全部延后至 feat/user-org-perm 合并
**决策:** ProjectSecurityChecker 补齐、LifecycleWriteGuard null skip 修复、@PreAuthorize 标注全部不在本计划实施,延后至 feat/user-org-perm 合并后统一用 @PreAuthorize 处理。
| 优势 | 劣势 |
|------|------|
| 避免重复改造(静态工具类 → @PreAuthorize | P0 越权漏洞持续暴露 |
| 不与另一个会话冲突 | 已知技术债 |
**理由:** 用户确认纯等待不兜底。feat/user-org-perm 提供 @PreAuthorize 框架后pms-base 直接标注注解,无需中间态。
---
## High-Level Technical Design
### 阶段依赖关系
```mermaid
graph LR
U1[U1 AES GCM 迁移] --> U4[U4 手机邮箱加密]
U1 --> U6[U6 测试补齐]
U2[U2 @AuditLog]
U3[U3 BigDecimal 迁移] --> U6
U5[U5 入参校验] --> U3
U7[U7 Feign 熔断]
U8[U8 @Transactional]
U9[U9 @Lazy 文档化]
```
### AES GCM 加密数据流
```
明文身份证号 → AesEncryptUtil.encrypt(plainText)
→ AES-256-GCM 加密key 从 Nacos 加载IV 用 SecureRandom 生成)
→ 密文 + IV 存储IV 前缀拼接密文)
→ HMAC-SHA256(plainText, serverSecret) 存储到 idNoHash 字段
查询身份证号 → AesEncryptUtil.hash(queryIdNo) → 数据库 WHERE idNoHash = ?
```
### BigDecimal→Long 迁移数据流
```
Flyway V13: ALTER TABLE t_contract MODIFY amount BIGINT;
迁移逻辑UPDATE t_contract SET amount = ROUND(amount * 100, 0);
Entity: BigDecimal amount → Long amountPriceFen
DTO: private Long xxxPriceFen
Service: Long 计算,避免 BigDecimal
API: {"monthlyRentPriceFen": 1234567} // 表示 12345.67 元
```
---
## Scope Boundaries
### In Scope
- pms-base 模块 AES GCM 加密重构 + 历史数据迁移
- pms-base 模块 BigDecimal→Long 全链路迁移
- pms-base 模块手机号/邮箱加密
- pms-base 模块 @AuditLog 标注 + PII 脱敏
- pms-base 模块测试覆盖率补齐
- pms-base 模块入参校验、Feign 熔断、@Transactional 补齐
- pms-base 的 Flyway 迁移脚本V12-V14
### Out of Scope
- Spring Security 链路重构(由 `feat/user-org-perm` worktree 承担)
- @PreAuthorize 注解框架本身(由 `feat/user-org-perm` 承担)
- @AuditLog AOP 切面基础设施已存在feat/user-org-perm 可能增强)
- 权限码体系设计(由 `feat/user-org-perm` 承担)
- 前端改动(除 DTO 字段名变更协调外)
- 其他微服务pms-charge/pms-auth/pms-operation 等)
### Deferred to feat/user-org-perm 合并后
- ProjectSecurityChecker 跨项目校验补齐19+ ServiceImpl
- LifecycleWriteGuard null skip 修复
- @PreAuthorize 业务 Controller 标注
- isSystemCall() 信任边界定义
- **已知风险**P0 越权漏洞LifecycleWriteGuard null skip + 30/31 Controller 零鉴权)持续暴露
### Deferred to Follow-Up Work
- pms-charge 等其他微服务的 BigDecimal→Long 迁移(如存在)
- 全仓 Feign 熔断统一配置(本计划仅修 pms-base 调用方)
- 前端 DTO 字段名变更适配pms-base 后端先行,前端后续排期)
- AES key 轮换策略V1 文档化V2 评估 KMS
---
## Implementation Units
### 阶段 1P0 安全紧急修复
### U1. AES 加密 GCM 全量迁移
**Goal:** 修复 AesEncryptUtil 的 ECB 模式、硬编码 key、idNoHash 未填充、异常吞掉四大漏洞,迁移历史数据到 GCM。
**Requirements:** R1, SC1
**Dependencies:** 无前置Hutool GCM 支持 POC
**Files:**
- `backend/pms-base/src/main/java/com/pms/base/util/AesEncryptUtil.java`修改GCM 模式 + Nacos key 强制 + 异常抛出 + idNoHash 同步填充 + SecureRandom IV
- `backend/pms-base/src/main/java/com/pms/base/service/impl/OwnerServiceImpl.java`修改create/update 时填充 idNoHash
- `backend/pms-base/src/main/java/com/pms/base/service/impl/TenantServiceImpl.java`(修改:同上)
- `backend/pms-base/src/main/resources/application.yml`(修改:统一为 `app.encrypt.aes-key`,移除 `crypto.aes.key`,无默认值)
- `backend/pms-base/src/main/resources/db/migration/V12__migrate_aes_ecb_to_gcm.java`新建Java-based Flyway 迁移ECB→GCM + idNoHash 填充)
- `backend/pms-base/src/test/java/com/pms/base/util/AesEncryptUtilTest.java`新建GCM 加解密测试)
**Approach:**
1. **前置 POC2-4 小时)**:验证 Hutool `cn.hutool.crypto.symmetric.AES` 是否支持 GCM 模式。若不支持,切 JDK 原生 `Cipher.getInstance("AES/GCM/NoPadding")`。POC 失败才降级为最低修复。
2. AesEncryptUtil 重构GCM 模式(`Mode.GCM`、`Padding.NoPadding` 或 JDK 原生);构造函数移除默认值,未配置 key 时抛 `IllegalStateException``encrypt()` 方法生成随机 IV`SecureRandom`IV 与密文拼接存储,同时填充 HMAC-SHA256 hash`decrypt()` 异常抛 `BusinessException` 而非返回 null`padKey()` 替换为 `SHA-256` 派生 keyKDF不再用 0 填充。
3. OwnerServiceImpl/TenantServiceImpl`setIdNo(cryptoUtil.encrypt(...))` 后增加 `setIdNoHash(cryptoUtil.hash(...))`
4. Flyway V12Java-based对历史 ECB 数据迁移。Java 迁移逻辑:对每条记录,解密 ECB用旧 key→ 重新用 GCM 加密(用新 key + 随机 IV→ 更新 idNo 字段 + 填充 idNoHash。停机迁移。
5. 配置文件:统一为 `app.encrypt.aes-key`,移除 `crypto.aes.key`无默认值。Nacos 配置中心管理实际 key。
**Patterns to follow:** 现有 AesEncryptUtil 的 `@Component` + `@Value` 注入模式Hutool `cn.hutool.crypto.symmetric.AES` API 或 JDK 原生 `Cipher` API。
**Test scenarios:**
- 加密后解密返回原文GCM 模式)
- 相同明文加密结果不同IV 随机)
- HMAC-SHA256 hash 返回固定长度,相同输入相同输出
- 未配置 key 时启动失败
- 解密失败抛 BusinessException
- 历史数据迁移脚本ECB 密文 → GCM 密文 + idNoHash 填充
- idNoHash 在 encrypt 后非空
- IV 每次不同CSPRNG 验证)
**Verification:** AesEncryptUtilTest 全部通过yml 无默认值时启动失败V12 迁移后 idNoHash 字段全部非空GCM 密文长度正确12 字节 IV + 密文 + 16 字节 tag
---
### 阶段 2P1 加固
### U2. @AuditLog 业务方法全覆盖 + PII 脱敏
**Goal:** 28 个未标注 @AuditLog 的 Controller 批量补齐注解,覆盖所有写操作,涉及 PII 的方法禁用 recordParams。
**Requirements:** R3, SC3
**Dependencies:** U1AesEncryptUtil 异常修复后,审计日志记录更准确)
**Files:**
- `backend/pms-base/src/main/java/com/pms/base/controller/OwnerController.java`(修改)
- `backend/pms-base/src/main/java/com/pms/base/controller/TenantController.java`(修改)
- `backend/pms-base/src/main/java/com/pms/base/controller/DeviceController.java`(修改)
- `backend/pms-base/src/main/java/com/pms/base/controller/ContractController.java`(修改)
- 其他 24 个业务 Controller评审清单
- `backend/pms-base/src/test/java/com/pms/base/controller/AuditLogCoverageTest.java`(新建:覆盖率验证测试)
**Approach:**
1. 每个业务 Controller 的 create/update/delete/状态流转方法标注 `@AuditLog(module="base", type="CREATE", description="创建业主", recordParams=false)`
2. 注解属性使用实际定义:`module`(模块,如 "base")、`type`(操作类型,如 "CREATE"/"UPDATE"/"DELETE")、`description`(人类可读描述)、`recordParams`(是否记录参数)、`recordResult`(是否记录结果)。
3. 涉及 PIIidNo/phone/email的方法`recordParams=false`(默认)。
4. 关键操作(如状态流转、审批):`recordParams=true` 但参数中不含 PII。
5. 读操作list/get暂不标注避免日志膨胀。
6. feat/user-org-perm 合并后,若 @AuditLog 字段增强,统一对齐。
**Patterns to follow:** 现有 LifecycleController 的 `@AuditLog(module = "base", type = "CREATE", description = "发起阶段流转")` 使用模式。
**Test scenarios:**
- Controller create 方法调用后t_audit_log 表新增一条记录
- 记录的 module/type/description/userId/projectId 字段正确
- 涉及 PII 的方法 recordParams=falset_audit_log.params 字段为空
- 读操作不产生审计日志
- 异常情况下审计日志仍记录(操作失败也留痕)
**Verification:** AuditLogCoverageTest 通过;所有写操作 Controller 方法均有 @AuditLog 注解;涉及 PII 的方法 recordParams=false。
---
### U3. BigDecimal→Long 全链路迁移
**Goal:** 14 个 entity 的 BigDecimal 金额字段迁移到 LongDDL 同步变更DTO 使用 `xxxPriceFen` 字段名。
**Requirements:** R4, SC4
**Dependencies:** U5入参校验先行确保迁移期间数据合法
**Files:**
- `backend/pms-base/src/main/java/com/pms/base/entity/Contract.java`修改amount/deposit BigDecimal→Long字段名 `amountPriceFen`/`depositPriceFen`
- `backend/pms-base/src/main/java/com/pms/base/entity/LeaseContract.java`(修改)
- `backend/pms-base/src/main/java/com/pms/base/entity/WorkshopLease.java`(修改)
- `backend/pms-base/src/main/java/com/pms/base/entity/PublicRevenue.java`(修改)
- `backend/pms-base/src/main/java/com/pms/base/entity/CamCharge.java`(修改)
- `backend/pms-base/src/main/java/com/pms/base/entity/Device.java`(修改)
- `backend/pms-base/src/main/java/com/pms/base/entity/DeviceMaintenance.java`(修改)
- `backend/pms-base/src/main/java/com/pms/base/entity/EnergyMeter.java`(修改)
- `backend/pms-base/src/main/java/com/pms/base/entity/Room.java`(修改)
- `backend/pms-base/src/main/java/com/pms/base/entity/OwnerRoom.java`(修改)
- `backend/pms-base/src/main/java/com/pms/base/entity/Project.java`(修改)
- `backend/pms-base/src/main/java/com/pms/base/entity/Building.java`(修改)
- `backend/pms-base/src/main/java/com/pms/base/entity/Floor.java`(修改)
- `backend/pms-base/src/main/java/com/pms/base/entity/Renovation.java`(修改)
- `backend/pms-base/src/main/resources/db/migration/V13__migrate_bigdecimal_to_long.sql`新建DECIMAL→BIGINT× 100
- 对应 DTO 文件(字段名改为 `xxxPriceFen`
- 对应 ServiceImpl 文件(金额计算逻辑调整)
- 对应 Controller 文件DTO 字段名适配)
- 单元测试文件(金额计算测试)
**Approach:**
1. Flyway V13 迁移:`ALTER TABLE t_contract MODIFY amount BIGINT;` + `UPDATE t_contract SET amount = ROUND(amount * 100, 0);`(对所有金额字段)。
2. Entity 字段类型 BigDecimal→Long字段名改为 `xxxPriceFen`(如 `amountPriceFen`、`depositPriceFen`、`monthlyRentPriceFen`)。
3. DTO 字段统一命名 `xxxPriceFen`,与 CLAUDE.md 一致。
4. Service 层金额计算:加减直接 Long 运算;乘除用 `BigDecimal` 中间计算后 `setScale(0, RoundingMode.HALF_UP).longValueExact()` 转回 Long。
5. Controller DTO 字段名变更,前端需协调(前端适配作为 follow-up
**Patterns to follow:** pms-charge 已用 Long的模式InternalController BigDecimal 修复中的 `setScale(0, RoundingMode.HALF_UP).longValueExact()` 模式。
**Test scenarios:**
- 金额 123.45 元 → 存储 12345
- 金额计算100.00 + 50.50 = 150.50 → 10000 + 5050 = 15050
- 金额除法100 元 ÷ 3 = 33.33 元 → 10000 ÷ 3 = 3333HALF_UP
- Flyway 迁移DECIMAL(15,2) 123.45 → BIGINT 12345
- DTO 序列化:`{"monthlyRentPriceFen": 123456}`
**Verification:** V13 迁移成功;所有金额字段无 BigDecimal 残留金额计算测试通过DTO 字段命名统一 `xxxPriceFen`
---
### U4. 手机号/邮箱加密
**Goal:** Owner/Tenant/Enterprise 实体的 phone/email 字段采用 HMAC-SHA256 hash + GCM 加密 + 后4位 hash 索引模式。
**Requirements:** R5, SC5
**Dependencies:** U1AesEncryptUtil GCM 加固后提供可靠加密)
**Files:**
- `backend/pms-base/src/main/java/com/pms/base/entity/Owner.java`修改phone/email 拆分为加密字段 + hash 字段 + 后4位 hash 字段)
- `backend/pms-base/src/main/java/com/pms/base/entity/Tenant.java`(修改)
- `backend/pms-base/src/main/java/com/pms/base/entity/Enterprise.java`(修改:仅 email无 phone
- `backend/pms-base/src/main/java/com/pms/base/service/impl/OwnerServiceImpl.java`(修改:加密 + 填充 hash + 后4位 hash
- `backend/pms-base/src/main/java/com/pms/base/service/impl/TenantServiceImpl.java`(修改)
- `backend/pms-base/src/main/java/com/pms/base/service/impl/EnterpriseServiceImpl.java`(修改)
- `backend/pms-base/src/main/resources/db/migration/V14__encrypt_phone_email.sql`(新建:新增 phone_hash/email_hash/phone_last4_hash/email_last4_hash 字段 + 索引 + 历史数据 Java 迁移脚本)
- 查询接口修改phone/email 查询走 hash 精确匹配或后4位 hash 尾号查询)
- 单元测试
**注意:** `Project.java` 无 phone/email 字段,不涉及本单元。
**Approach:**
1. Entity 拆分:`phone`GCM 加密存储)+ `phoneHash`HMAC-SHA256 精确查询)+ `phoneLast4Hash`HMAC-SHA256 of 后4位尾号查询`email` + `emailHash` + `emailLast4Hash`
2. Service create/update`setPhone(cryptoUtil.encrypt(plainPhone))` + `setPhoneHash(cryptoUtil.hash(plainPhone))` + `setPhoneLast4Hash(cryptoUtil.hash(plainPhone.substring(7)))`
3. 查询:精确查询 `WHERE phone_hash = cryptoUtil.hash(queryPhone)`;尾号查询 `WHERE phone_last4_hash = cryptoUtil.hash(queryLast4)`。不支持 like 前缀模糊查询。
4. API 响应:通过 ResponseBodyAdvice 或 DTO 转换层统一解密 phone/email。
5. Flyway V14新增 `phone_hash`/`email_hash`/`phone_last4_hash`/`email_last4_hash` 字段 + 索引;历史明文数据 Java 迁移脚本批量加密 + 填充 hash。
**Patterns to follow:** U1 中 idNo/idNoHash 的加密 + hash 模式。
**Test scenarios:**
- 手机号加密存储,明文不出现数据库
- phoneHash 精确查询返回正确记录
- phoneLast4Hash 尾号查询后4位返回正确记录
- like 前缀模糊查询不再支持(测试确认抛异常或返回空)
- API 响应解密后返回明文手机号
- 历史数据迁移后所有 phone_hash/phone_last4_hash 非空
- Enterprise 仅 email 加密(无 phone 字段)
**Verification:** V14 迁移成功phone/email 加密存储hash 精确查询 + 尾号查询正确。
---
### U5. 入参校验补齐
**Goal:** 11 个 Controller 的 Entity @RequestBody@Valid + 字段级校验注解。
**Requirements:** R7
**Dependencies:** 无
**Files:**
- `backend/pms-base/src/main/java/com/pms/base/controller/OwnerController.java`(修改:@Valid
- `backend/pms-base/src/main/java/com/pms/base/controller/TenantController.java`(修改)
- 其他 9 个 Controller
- 对应 Entity 文件(修改:字段加 @NotBlank/@Pattern/@Size
- `backend/pms-base/src/main/java/com/pms/base/exception/GlobalExceptionHandler.java`(修改:处理 MethodArgumentNotValidException
**Approach:**
1. Controller 方法参数加 `@Valid` 注解。
2. Entity 关键字段加校验注解phone `@Pattern(regexp="^1[3-9]\\d{9}$")`idNo `@Pattern(regexp="^\\d{17}[\\dXx]$")`name `@NotBlank`amount `@PositiveOrZero`
3. GlobalExceptionHandler 处理 `MethodArgumentNotValidException`,返回 400 + 字段错误信息。
4. 短期方案Entity 加注解),长期随 U3 BigDecimal 迁移时引入 DTO 层。
**Patterns to follow:** Spring Validation 标准模式;`@Pattern`/`@NotBlank`/`@Size`。
**Test scenarios:**
- phone="13800138000":校验通过
- phone="abc":返回 400 + 错误信息
- idNo 格式错误:返回 400
- name 为空:返回 400
- amount 为负数:返回 400
**Verification:** 非法入参返回 400合法入参正常处理。
---
### U6. 测试覆盖率补齐
**Goal:** 13 个零测试 ServiceImpl 补齐单元测试,覆盖加密/金额计算等高风险逻辑。
**Requirements:** R6, SC6
**Dependencies:** U1AesEncryptUtil 加固后测试基础、U3BigDecimal 迁移后测试新逻辑)
**Files:**
- `backend/pms-base/src/test/java/com/pms/base/service/OwnerServiceImplTest.java`(新建)
- `backend/pms-base/src/test/java/com/pms/base/service/TenantServiceImplTest.java`(新建)
- `backend/pms-base/src/test/java/com/pms/base/service/DeviceServiceImplTest.java`(新建/补齐)
- 其他 10 个零测试 ServiceImpl 的测试文件
- 现有测试文件(修改:适配新签名)
**Approach:**
1. 优先覆盖含加密逻辑OwnerServiceImpl/TenantServiceImpl、金额计算ContractServiceImpl 等)的服务。
2. 测试模式Mockito @ExtendWith + @Mock 依赖 + @InjectMocks 被测服务。
3. @Lazy 字段用 ReflectionTestUtils.setField 手动注入(参考 docs/solutions/conventions/lazy-injection-reflectiontestutils-testing.md
4. 覆盖率目标:每个 ServiceImpl 的 create/update/delete/查询方法都有测试。
**Patterns to follow:** 现有 LifecycleServiceTest 的测试模式docs/solutions/conventions/lazy-injection-reflectiontestutils-testing.md 的 @Lazy 注入模式。
**Test scenarios:**
- 各 ServiceImpl 的 CRUD 主流程
- 加密字段填充验证idNoHash/phoneHash/phoneLast4Hash
- 金额计算验证Long 分)
- 异常传播验证
**Verification:** 13 个 ServiceImpl 都有测试文件;整体测试覆盖率 ≥ 60%。
---
### 阶段 3架构优化 + P3 清理
### U7. Feign 熔断 + AuthClient fallback 修复
**Goal:** pms-base 的 Feign 客户端配置 Resilience4j 熔断AuthClient fallback 抛异常而非返回 null。
**Requirements:** R8
**Dependencies:** 无
**Files:**
- `backend/pms-base/build.gradle`(修改:引入 `resilience4j-spring-boot3` 依赖)
- `backend/pms-base/src/main/resources/application.yml`修改Resilience4j 配置)
- `backend/pms-base/src/main/java/com/pms/base/feign/AuthClient.java`修改fallback 抛 BusinessException
- `backend/pms-base/src/test/java/com/pms/base/feign/AuthClientFallbackTest.java`(新建)
**注意:** AuthClient 位于 `com.pms.base.feign` 包(非 `client/`fallback 是 AuthClient 内部类 `AuthClientFallbackFactory implements FallbackFactory<AuthClient>`,无独立 AuthClientFallback.java 文件。项目使用 Gradle非 Maven
**Approach:**
1. `build.gradle` 引入 Resilience4j 依赖,配置熔断参数(失败率阈值 50%、慢调用阈值 2s、半开状态 10 个请求)。
2. AuthClient 的 `@FeignClient` 配置 fallback 类fallback 方法抛 `BusinessException(ErrorCode.AUTH_SERVICE_UNAVAILABLE)`
3. 其他 Feign 客户端(如有)同样配置 fallback。
**Patterns to follow:** Spring Cloud OpenFeign + Resilience4j 标准模式。
**Test scenarios:**
- AuthClient 调用成功:正常返回
- AuthClient 调用超时:触发熔断
- AuthClient fallback 抛异常
- 熔断器开启状态:快速失败
**Verification:** AuthClientFallbackTest 通过;生产环境 Resilience4j 配置生效。
---
### U8. @Transactional 补齐
**Goal:** 3 个 delete 方法补 @Transactional
**Requirements:** R9
**Dependencies:** 无AesEncryptUtil 异常处理已在 U1 完成)
**Files:**
- `backend/pms-base/src/main/java/com/pms/base/service/impl/DeviceCategoryServiceImpl.java`修改delete 补 @Transactional
- `backend/pms-base/src/main/java/com/pms/base/service/impl/ContractTypeServiceImpl.java`(修改)
- `backend/pms-base/src/main/java/com/pms/base/service/impl/ProjectServiceImpl.java`(修改)
- 对应测试文件
**Approach:**
1. 3 个 delete 方法加 `@Transactional(rollbackFor = Exception.class)`
**Patterns to follow:** 现有 12 个 delete 方法已补 @Transactional 的模式(参考 ContractServiceImpl
**Test scenarios:**
- delete 失败时事务回滚
**Verification:** 3 个 delete 方法有 @Transactional
---
### U9. @Lazy 循环依赖文档化
**Goal:** LifecycleService ↔ ArchiveService 的 @Lazy 循环依赖文档化为已知设计,避免后续重复踩坑。
**Requirements:** R10
**Dependencies:** 无
**Files:**
- `CONCEPTS.md`(修改:新增 "@Lazy 循环依赖" 条目)
- `docs/solutions/conventions/lazy-injection-reflectiontestutils-testing.md`(修改:补充设计理由)
**Approach:**
1. CONCEPTS.md 新增条目解释 @Lazy 循环依赖的存在原因(归档恢复需同步语义,事件总线异步不适用)和测试注意事项。
2. 现有 docs/solutions/conventions/lazy-injection-reflectiontestutils-testing.md 补充设计理由段落。
**Test scenarios:** 无(文档单元)
**Verification:** CONCEPTS.md 新增条目;现有文档补充。
---
## Open Questions
### OQ-1. AES key 配置中心选型(已解决)
**状态:** 已解决 — Nacos 配置中心
Nacos 已在 application.yml 中配置(`spring.cloud.nacos.config`。AES key 从 Nacos 加载key 不进 VCS启动时校验非默认值。
### OQ-2. 历史数据迁移窗口(已解决)
**状态:** 已解决 — 停机迁移
idNo 数据量小,停机 30 分钟可完成。简单,无混合存储问题。
### OQ-3. BigDecimal 迁移期间前端兼容
**状态:** 需协调
DTO 字段名从 `amount`BigDecimal改为 `xxxPriceFen`Long前端需同步适配。
**选项:**
- ① 后端先行,前端后续排期适配。
- ② 后端等前端同步发布。
- ③ 后端提供双字段amount + priceFen过渡期。
**默认:** ① 后端先行(前端适配作为 follow-up
---
## System-Wide Impact
### 受影响方
- **pms-base 服务**:全部修复涉及,是主要变更方。
- **pms-charge 服务**U3 BigDecimal 迁移可能影响跨服务调用(如 InternalController 已修复)。
- **前端**U3 DTO 字段名变更需适配U4 phone/email API 响应格式可能变化。
- **运维**U1 AES key 配置需协调所有环境 NacosU1/U3/U4 Flyway 迁移需停机。
- **DBA**V12/V13/V14 迁移脚本需 review。
### 跨服务协调
- feat/user-org-perm 合并是鉴权相关工作的前置条件(不在本计划)。
- pms-charge 金额单位需与 U3 保持一致(如 pms-charge 未用 Long需后续迁移
---
## Risks & Dependencies
### 风险
| 风险 | 概率 | 影响 | 缓解 |
|------|------|------|------|
| Hutool 不支持 GCM 模式 | 中 | 高 | 前置 POC 验证;不支持则切 JDK 原生或 BouncyCastle |
| AES key 迁移导致历史数据无法解密 | 中 | 高 | 迁移脚本充分测试;保留旧 key 用于回滚 |
| BigDecimal 迁移期间金额计算错误 | 中 | 高 | 单元测试覆盖所有金额计算场景;灰度发布 |
| P0 越权漏洞持续暴露(鉴权延后) | 高 | 高 | 文档化为已知技术债feat/user-org-perm 合并后优先处理 |
| Feign 熔断参数不当导致误熔断 | 中 | 中 | 灰度观察熔断指标;参数可配置 |
### 依赖
- **Hutool GCM 支持**U1 前置 POC 验证。
- **Nacos 配置中心**U1 AES key 加载依赖 Nacos 已配置。
- **Flyway 版本兼容**V12+ 迁移脚本需与现有 V1-V11 兼容V11 已被项目生命周期管理占用)。
---
## Acceptance Examples
### AE1. AES 加密修复验证
**场景:** 用户创建业主记录,填写身份证号 `110101199001011234`
**预期:**
- 数据库 `t_owner.id_no` 存储密文GCM 加密,含 IV 前缀 + 16 字节认证标签)
- 数据库 `t_owner.id_no_hash` 存储 HMAC-SHA256 哈希
- 通过 `idNoHash` 查询能找到该记录
- API 响应中 `idNo` 字段返回明文(解密后)
- 相同身份证号两次加密产生不同密文IV 随机)
### AE2. 金额计算准确性
**场景:** 创建合同,金额 1234.56 元。
**预期:**
- API 请求:`{"monthlyRentPriceFen": 123456}`
- 数据库 `t_contract.amount` 存储 `123456`Long
- 查询 API 响应:`{"monthlyRentPriceFen": 123456}`
### AE3. 手机号加密存储
**场景:** 用户创建业主,手机号 `13800138000`
**预期:**
- 数据库 `t_owner.phone` 存储密文GCM 加密)
- 数据库 `t_owner.phone_hash` 存储 HMAC-SHA256 哈希
- 数据库 `t_owner.phone_last4_hash` 存储后4位 `8000` 的 HMAC-SHA256 哈希
- 精确查询 `phone_hash = hash("13800138000")` 返回该记录
- 尾号查询 `phone_last4_hash = hash("8000")` 返回该记录
- API 响应中 `phone` 返回明文 `13800138000`
### AE4. 审计日志完整 + PII 脱敏
**场景:** 管理员修改业主信息(含身份证号)。
**预期:**
- `t_audit_log` 新增一条记录
- 记录字段:`user_id`、`project_id`、`module="base"`、`type="UPDATE"`、`description="修改业主信息"`、`record_params=false`
- `t_audit_log.params` 字段为空(因涉及 PIIrecordParams=false
- 读操作不产生审计日志
---
## Sources & Research
- **pms-base 模块评审报告**2026-07-02 会话):问题清单
- **CLAUDE.md**:金额单位 Long(分) 约定、外部系统对接规范
- **CONCEPTS.md**lifecycle_stage、@Lazy 循环依赖等领域名词
- **docs/solutions/conventions/lazy-injection-reflectiontestutils-testing.md**@Lazy + ReflectionTestUtils 测试模式
- **feat/user-org-perm plan**`docs/plans/2026-07-01-005-refactor-user-org-perm-plan.md`Spring Security 重构范围,确认不包含加密基础设施)
- **Hutool 文档**`cn.hutool.crypto.symmetric.AES` GCM 模式支持(需 POC 验证)
- **JDK Cipher API**`Cipher.getInstance("AES/GCM/NoPadding")` 备选方案
- **《个人信息保护法》第 51 条**:数据安全保护义务
- **《网络安全法》第 21 条**:日志留存要求

View File

@ -99,6 +99,16 @@ Mockito `@InjectMocks` 的注入策略是 **构造器注入优先**:当被测
**反模式:不要试图去掉 `@Lazy` 改用 `ApplicationContextAware``ObjectProvider`**——这些虽然能避免循环依赖,但引入了不必要的复杂度。`@Lazy` 是 Spring 官方推荐的循环依赖解法,测试侧只需一行 `setField` 即可适配。
## Design Rationale: 为什么不用事件总线解耦
`LifecycleServiceImpl``ArchiveServiceImpl` 的互调看似可以用 MQ 事件总线解耦(归档完成发事件 → 生命周期监听推进阶段),但当前设计选择 `@Lazy` 同步调用,理由:
1. **事务一致性**:归档恢复要求"标记归档只读解除 + 生命周期阶段推进"在同一事务内完成。若用 MQ 异步事件,归档只读已解除但阶段推进失败,项目会处于不一致态(可写但 lifecycle_stage=ARCHIVED
2. **失败语义清晰**:同步调用失败直接抛异常回滚事务;异步事件失败需额外设计重试 + 死信队列 + 幂等,复杂度远高于一行 `@Lazy`
3. **调用路径短**:仅两个 Service 互调,无扇出场景。事件总线的优势在于一对多解耦,此处不适用。
若未来归档流程演进为多服务协作(如归档后通知计费服务结算、通知资产服务移交),应将跨服务部分拆为 MQ 事件,但 `LifecycleService ↔ ArchiveService` 的同事务互调保留 `@Lazy`
## Related
- [CONCEPTS.md — 项目生命周期管理](../../CONCEPTS.md)(本模式首次出现的业务场景)

Some files were not shown because too many files have changed in this diff Show More