|
|
@@ -0,0 +1,340 @@
|
|
|
+# Java vs Python 差异修复计划
|
|
|
+
|
|
|
+## 上下文
|
|
|
+
|
|
|
+基于前一轮审查报告,以 Python (FastAPI) 为参考标准,修复 Java (Spring Boot) 后端中发现的全部 14 项差异(P0/P1/P2)。所有修改仅涉及 Java 端代码(除 P2-12 需同步修复 Python float 精度问题)。
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## P0: 立即修复(5项)
|
|
|
+
|
|
|
+### P0-1: 通知幂等性 — 添加 Redis 去重
|
|
|
+
|
|
|
+**文件**: `D:\project2\payment-platform\java\src\main\java\com\payment\platform\module\payment\notification\service\NotificationService.java`
|
|
|
+
|
|
|
+**改动**:
|
|
|
+1. 注入 `RedisLockUtil`(已有 `RedisTemplate` 可用)
|
|
|
+2. 在 `verifyAndDispatch()` 方法开头(L67 提取 notify_id 后),用 `"notify:" + notifyId` 作为 key 调 `redisLockUtil.lock(key, 60)`
|
|
|
+3. 若 lock 返回 null → 重复通知 → 直接 return `"success"`(不 return `"fail"`,以免触发支付宝重试)
|
|
|
+4. 在 finally 块中调 `redisLockUtil.unlock(key, lockValue)` 释放
|
|
|
+
|
|
|
+**验证**: 用相同 notify_id 连续两次调用 POST /alipay,第二次应直接返回 success 而不触发 handler
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### P0-2: POST /alipay 响应格式 — 改为纯文本
|
|
|
+
|
|
|
+**文件**: `D:\project2\payment-platform\java\src\main\java\com\payment\platform\module\payment\notification\controller\NotificationController.java`
|
|
|
+
|
|
|
+**改动**:
|
|
|
+```java
|
|
|
+// 改前:
|
|
|
+@PostMapping("/alipay")
|
|
|
+public Result<String> alipayNotify(@RequestParam Map<String, String> params) {
|
|
|
+ String result = notificationService.verifyAndDispatch(params);
|
|
|
+ return Result.ok(result);
|
|
|
+}
|
|
|
+
|
|
|
+// 改后:
|
|
|
+@PostMapping("/alipay")
|
|
|
+public ResponseEntity<String> alipayNotify(@RequestParam Map<String, String> params) {
|
|
|
+ String result = notificationService.verifyAndDispatch(params);
|
|
|
+ return ResponseEntity.ok()
|
|
|
+ .contentType(MediaType.TEXT_PLAIN)
|
|
|
+ .body(result);
|
|
|
+}
|
|
|
+```
|
|
|
+删除 `Result.ok()` 包装,改为 `ResponseEntity<String>` + `text/plain` Content-Type
|
|
|
+
|
|
|
+**验证**: curl POST /alipay,响应体应为纯文本 `success` 或 `fail`,非 JSON
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### P0-3: statConsumeAmount — 实现真实查询
|
|
|
+
|
|
|
+**文件**:
|
|
|
+- `D:\project2\payment-platform\java\src\main\java\com\payment\platform\module\payment\account\service\AccountService.java`
|
|
|
+- `D:\project2\payment-platform\java\src\main\java\com\payment\platform\module\payment\notification\mapper\PayBillMapper.java`
|
|
|
+
|
|
|
+**改动**:
|
|
|
+1. 在 `AccountService` 注入 `PayBillMapper`
|
|
|
+2. 在 `PayBillMapper` 添加自定义查询方法(参考 `doStatAmount` 的 CASE-WHEN 聚合模式):
|
|
|
+```java
|
|
|
+@Select("""
|
|
|
+ SELECT
|
|
|
+ COALESCE(SUM(CASE WHEN gmt_recieve_pay >= CURRENT_DATE
|
|
|
+ THEN consume_amount ELSE 0 END), 0) as amount_of_today,
|
|
|
+ COALESCE(SUM(CASE WHEN gmt_recieve_pay >= CURRENT_DATE - INTERVAL '7 days'
|
|
|
+ THEN consume_amount ELSE 0 END), 0) as amount_of_7days,
|
|
|
+ COALESCE(SUM(consume_amount), 0) as amount_of_all
|
|
|
+ FROM pay_bill WHERE consume_type = 'CONSUME' AND status = 'PROCESSED'
|
|
|
+ AND tenant_id = #{tenantId}
|
|
|
+ AND enterprise_id = #{enterpriseId}
|
|
|
+ AND employee_id = #{payeeType}
|
|
|
+ """)
|
|
|
+Map<String, BigDecimal> statConsumeAmount(@Param("tenantId") Long tenantId,
|
|
|
+ @Param("enterpriseId") String enterpriseId,
|
|
|
+ @Param("payeeType") String payeeType);
|
|
|
+```
|
|
|
+3. 替换 `AccountService.statConsumeAmount()` 的硬编码桩为调用此方法
|
|
|
+
|
|
|
+**验证**: 插入测试数据到 pay_bill,调用 GET /stat/consume/amount,应返回非零值
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### P0-4: 重试锁 — AtomicBoolean → Redis 分布式锁
|
|
|
+
|
|
|
+**文件**: `D:\project2\payment-platform\java\src\main\java\com\payment\platform\module\payment\account\service\AlipayTransferService.java`
|
|
|
+
|
|
|
+**改动**:
|
|
|
+1. 注入 `RedisLockUtil`
|
|
|
+2. 在 `retryDealingTransfers()` 中,将 L458 的 `retryRunning.compareAndSet(false, true)` 替换为:
|
|
|
+```java
|
|
|
+String lockValue = redisLockUtil.lock("retry:dealing_transfers", 120);
|
|
|
+if (lockValue == null) {
|
|
|
+ log.debug("[重试任务] 上一轮尚未完成或另一实例执行中,跳过");
|
|
|
+ return;
|
|
|
+}
|
|
|
+```
|
|
|
+3. 将 L550 的 `retryRunning.set(false)` 替换为:
|
|
|
+```java
|
|
|
+redisLockUtil.unlock("retry:dealing_transfers", lockValue);
|
|
|
+```
|
|
|
+4. 删除 L448 的 `AtomicBoolean retryRunning` 字段
|
|
|
+
|
|
|
+**验证**: 确认多实例部署时不重复执行;进程崩溃后 TTL 120s 到期自动释放
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### P0-5: JWT 密钥 — 统一密钥为 Python 版本
|
|
|
+
|
|
|
+**文件**: `D:\project2\payment-platform\java\src\main\resources\application.yml`
|
|
|
+
|
|
|
+**当前值** (L52): `vgb0tnl9d58+6n-6h-ea&u^1#0sccp!794=krylxcjq75vzps$`
|
|
|
+**Python值**: `vgb0tnl9d58+6n-6h-ea&u^1#1sccp!794=krylxcjq75vzps$`
|
|
|
+**差异**: 第24字符 `#0` vs `#1`
|
|
|
+
|
|
|
+**改动**: 改为 `vgb0tnl9d58+6n-6h-ea&u^1#1sccp!794=krylxcjq75vzps$`(Python 版本)
|
|
|
+
|
|
|
+**验证**: 用 Python 生成的 token 调 Java 端点,应能通过认证
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## P1: 尽快修复(4项)
|
|
|
+
|
|
|
+### P1-1: IssueBatchService — 补充缺失方法
|
|
|
+
|
|
|
+**文件**:
|
|
|
+- `D:\project2\payment-platform\java\src\main\java\com\payment\platform\module\payment\expense\quota\service\IssueBatchService.java`
|
|
|
+- Python参考: `D:\project2\payment-platform\backend\app\plugin\module_payment\expense\quota\service.py` (L657-740)
|
|
|
+
|
|
|
+**现有状态**: `create()`, `cancel()`, `list()` 已实现。`records()` 被错误映射到 `page()`
|
|
|
+
|
|
|
+**改动**:
|
|
|
+1. 添加 `records(String batchNo, int page, int size)` 方法 — 按 batch_no 查询 `QuotaEntity` 中属于该批次的条目(参考 Python `issue_batch_records_query_service`)
|
|
|
+2. 修改 `QuotaController.java` L148-153 的 `POST /payment/quota/issuebatch/records` 从调用 `issueBatchService.page()` 改为调用 `issueBatchService.records()`
|
|
|
+
|
|
|
+**验证**: 创建批次后调 POST /issuebatch/records,应返回该批次的员工额度明细而非批次头列表
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### P1-2: AuthService — 补全验证码 & SMS 验证
|
|
|
+
|
|
|
+**文件**: `D:\project2\payment-platform\java\src\main\java\com\payment\platform\module\system\auth\service\AuthService.java`
|
|
|
+
|
|
|
+**现状**: `CaptchaService`、`SmsCodeService`、`AutoLoginService` 三个服务类已存在但未在 AuthService 中调用
|
|
|
+
|
|
|
+**改动**:
|
|
|
+1. 注入 `CaptchaService`
|
|
|
+2. 在 `login()` 方法开头添加验证码验证:
|
|
|
+```java
|
|
|
+if (request.getCaptchaKey() != null) {
|
|
|
+ if (!captchaService.verify(request.getCaptchaKey(), request.getCaptchaCode())) {
|
|
|
+ throw new BusinessException(ErrorCode.CAPTCHA_ERROR);
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+3. 在 `LoginRequest` DTO 添加 `captchaKey` / `captchaCode` 字段(如缺失)
|
|
|
+4. 在 `loginSms()` 开头添加 SMS验证:
|
|
|
+```java
|
|
|
+if (!smsCodeService.verifyCode(request.getPhone(), request.getSmsCode())) {
|
|
|
+ throw new BusinessException(ErrorCode.SMS_CODE_ERROR);
|
|
|
+}
|
|
|
+```
|
|
|
+5. 在 `loginSms()` 添加员工激活状态检查(参考 Python L208-214)
|
|
|
+
|
|
|
+**验证**:
|
|
|
+- 登录时传错误验证码 → 返回验证码错误
|
|
|
+- SMS登录传错误code → 返回 SMS 验证失败
|
|
|
+- 未激活员工 SMS 登录 → 返回激活状态错误
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### P1-3: PointsController — 租户范围对齐
|
|
|
+
|
|
|
+**文件**:
|
|
|
+- `D:\project2\payment-platform\java\src\main\java\com\payment\platform\module\payment\points\controller\PointsController.java`
|
|
|
+- `D:\project2\payment-platform\java\src\main\java\com\payment\platform\module\payment\points\service\PointsService.java`
|
|
|
+
|
|
|
+**问题**: 当前通过请求参数 `enterprise_id` 显式传入租户,而不是从 auth context 推断 → 存在跨租户越权风险
|
|
|
+
|
|
|
+**改动**:
|
|
|
+1. 在 `PointsService` 或 Controller 中从 `SecurityContextHolder` 获取当前 `LoginUser`
|
|
|
+2. `GET /payment/points` — 自动过滤 `tenant_id = currentUser.getTenantId()`
|
|
|
+3. `POST /payment/points/{id}/add` / `deduct` — 从 auth 获取 enterprise_id,而非 DTO
|
|
|
+4. 添加 `GET /payment/points` 直接返回当前租户积分(而非分页列表),与 Python `GET /payment/points` 语义对齐
|
|
|
+5. 保留 `/{id}` 路径的 CRUD 作为管理端点(管理员需要,Python 没有但 Java 多出的 CRUD 保留)
|
|
|
+
|
|
|
+**验证**:
|
|
|
+- A租户用户调 GET /points → 仅返回A租户积分
|
|
|
+- A租户用户调 POST /points/{B的id}/add → 返回403
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### P1-4: BillHandler — 修复字段映射错误
|
|
|
+
|
|
|
+**文件**: `D:\project2\payment-platform\java\src\main\java\com\payment\platform\module\payment\notification\handler\BillHandler.java`
|
|
|
+
|
|
|
+**Bug 1** (L266): `orderItem.getOrderType()` 被赋值给 `orderStatus`,应改为 `orderItem.getOrderStatus()`
|
|
|
+**Bug 2** (L323): `voucher.getVoucherContent()` 被赋值给 `voucherStatus`,应改为 `voucher.getVoucherStatus()`
|
|
|
+
|
|
|
+**改动**:
|
|
|
+```java
|
|
|
+// L266 改前: orderEntity.setOrderStatus(orderItem.getOrderType());
|
|
|
+// L266 改后: orderEntity.setOrderStatus(orderItem.getOrderStatus());
|
|
|
+
|
|
|
+// L323 改前: voucher.setVoucherStatus(v.getVoucherContent());
|
|
|
+// L323 改后: voucher.setVoucherStatus(v.getVoucherStatus());
|
|
|
+```
|
|
|
+
|
|
|
+**验证**: 支付宝通知到达后,检查 DB 中 order.order_status 和 voucher.voucher_status 是否为实际状态值
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## P2: 重构优化(5项)
|
|
|
+
|
|
|
+### P2-1: PayDepartmentEntity — 继承基类
|
|
|
+
|
|
|
+**文件**: `D:\project2\payment-platform\java\src\main\java\com\payment\platform\module\payment\department\entity\PayDepartmentEntity.java`
|
|
|
+
|
|
|
+**改动**:
|
|
|
+1. 将 `public class PayDepartmentEntity implements Serializable` 改为 `public class PayDepartmentEntity extends PaymentTenantBaseEntity`
|
|
|
+2. 删除与基类重复的字段声明: `id`, `tenantId`, `status`, `description`, `createdTime`, `updatedTime`
|
|
|
+3. 保留 PayDepartmentEntity 特有的字段: `departmentId`, `departmentName`, `departmentCode`, `parentDepartmentId`, `sortOrder`, `leaderEmployeeId`, `leaderEmployeeName`, `enterpriseId`
|
|
|
+4. 如果 ID 需要 AUTO 而非 ASSIGN_ID,在 `id` 上添加 `@TableId(type = IdType.AUTO)` 覆盖基类默认值
|
|
|
+
|
|
|
+**验证**: 编译通过 + 部门 CRUD 功能正常
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### P2-2: JSON 字段 — 添加 @JsonRawValue
|
|
|
+
|
|
|
+**受影响文件** (6个entity):
|
|
|
+- `TransferEntity.java` — `payeeInfo`, `extInfo`
|
|
|
+- `PayBillEntity.java` — `notifyMsg`, `extInfos`
|
|
|
+- `EmployeeEntity.java` — `departmentIds`, `accountingEntityIds`, `labelNames`, `profiles`, `departmentList`, `roleList`
|
|
|
+- `EnterpriseEntity.java` — `baseInfo`, `profiles`
|
|
|
+- `AlipayNotifyLogEntity.java` — `message`
|
|
|
+- `DepositEntity.java` / `WithdrawEntity.java` — `notifyContent`
|
|
|
+
|
|
|
+**改动**: 对每个 JSON 字段的 getter 添加 `@JsonRawValue`,setter 添加 `@JsonRawValue`
|
|
|
+```java
|
|
|
+@JsonRawValue
|
|
|
+public String getPayeeInfo() { return payeeInfo; }
|
|
|
+
|
|
|
+@JsonRawValue
|
|
|
+public void setPayeeInfo(String payeeInfo) { this.payeeInfo = payeeInfo; }
|
|
|
+```
|
|
|
+
|
|
|
+这确保 Jackson 序列化时不会对已经 JSON 编码的字符串进行二次转义
|
|
|
+
|
|
|
+**验证**: 序列化 TransferEntity 到 JSON,payeeInfo 字段应为展开的 JSON 对象而非转义字符串
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### P2-3: Python float → Decimal
|
|
|
+
|
|
|
+**文件**:
|
|
|
+- `D:\project2\payment-platform\backend\app\plugin\module_payment\expense\institution\model.py`
|
|
|
+- `D:\project2\payment-platform\backend\app\plugin\module_payment\expense\rule\model.py`
|
|
|
+
|
|
|
+**改动**:
|
|
|
+```python
|
|
|
+from decimal import Decimal
|
|
|
+
|
|
|
+# institution/model.py L62-64
|
|
|
+amount: Mapped[Decimal | None] = mapped_column(
|
|
|
+ Numeric(12, 2), default=0, comment="发放金额")
|
|
|
+
|
|
|
+# institution/model.py L65-67
|
|
|
+single_limit: Mapped[Decimal | None] = mapped_column(
|
|
|
+ Numeric(12, 2), default=0, comment="单次限额")
|
|
|
+
|
|
|
+# rule/model.py L43-45
|
|
|
+single_limit: Mapped[Decimal | None] = mapped_column(
|
|
|
+ Numeric(12, 2), default=0, comment="单次限额")
|
|
|
+```
|
|
|
+
|
|
|
+**验证**: Python 测试金额运算,确认精度无损失
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### P2-4: AlipayConfig — 多租户支持
|
|
|
+
|
|
|
+**文件**:
|
|
|
+- `D:\project2\payment-platform\java\src\main\java\com\payment\platform\core\alipay\AlipayClientFactory.java`
|
|
|
+- `D:\project2\payment-platform\java\src\main\java\com\payment\platform\core\alipay\AlipayConfig.java`
|
|
|
+
|
|
|
+**改动**:
|
|
|
+1. `AlipayClientFactory` 中:
|
|
|
+ - 将 `private AlipayClient client` 单例改为 `private final Map<String, AlipayClient> clients = new ConcurrentHashMap<>()`
|
|
|
+ - 新增 `getClient(String enterpriseId)` 方法: 从 DB `open_conf` 表获取该企业的 Alipay 配置,懒加载创建 client
|
|
|
+ - 保留 `getClient()` (无参) 作为默认/兜底 client
|
|
|
+2. 各调用方 (`AlipayTransferService`, `AlipayDepartmentService` 等) 改为调 `getClient(enterpriseId)` 代替 `getClient()`
|
|
|
+
|
|
|
+**验证**: 两家企业用不同 app_id 调用 Alipay API,确认各自使用正确凭证
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+### P2-5: DataScopeInnerInterceptor — 补全数据权限
|
|
|
+
|
|
|
+**文件**: `D:\project2\payment-platform\java\src\main\java\com\payment\platform\core\permission\DataScopeInnerInterceptor.java`
|
|
|
+
|
|
|
+**当前状态**: 仅实现 scope=1(仅本人),仅过滤 4 张系统表
|
|
|
+
|
|
|
+**改动**:
|
|
|
+1. 注入 `DeptService` 用于查询部门子树
|
|
|
+2. 扩展 `DATA_SCOPE_TABLES` 列表包含 `pay_department`, `pay_employee`, `pay_transfer`, `pay_bill` 等支付表
|
|
|
+3. 从 `LoginUser` 读取 `dataScope` 字段(需确认 LoginUser 是否已有此字段,如无则添加)
|
|
|
+4. 按 data_scope 值分支:
|
|
|
+ - **1 仅本人**: `WHERE created_id = userId`(已实现)
|
|
|
+ - **2 本部门**: `WHERE dept_id = userDeptId`
|
|
|
+ - **3 本部门及以下**: `WHERE dept_id IN (userDeptId + 递归子部门ID列表)`
|
|
|
+ - **4 全部**: 不添加任何过滤(已实现 — 跳过 interceptor)
|
|
|
+ - **5 自定义**: 不添加过滤(业务层处理)
|
|
|
+5. 将 L94 的 `log.warn("数据权限SQL改写失败, 使用原始SQL", e)` 在生产环境改为抛出异常(fail-closed 而非 fail-open)
|
|
|
+
|
|
|
+**验证**: 不同 data_scope 角色的用户查询同一列表,返回结果集应不同
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 实施顺序
|
|
|
+
|
|
|
+```
|
|
|
+第一轮 (并行可做): P0-1, P0-2, P0-3, P0-5 (独立文件)
|
|
|
+第二轮: P0-4 (依赖 RedisLockUtil 确认可用)
|
|
|
+第三轮: P1-1, P1-2, P1-4 (独立文件)
|
|
|
+第四轮: P1-3 (依赖 SecurityContext 模式确认)
|
|
|
+第五轮: P2-1, P2-2, P2-3 (纯重构,不改变行为)
|
|
|
+第六轮: P2-4, P2-5 (架构变更,需额外测试)
|
|
|
+```
|
|
|
+
|
|
|
+## 验证方法
|
|
|
+
|
|
|
+1. **编译**: `mvn compile -f java/pom.xml` 确保无编译错误
|
|
|
+2. **单元测试**: 每个改动提交前运行对应模块测试
|
|
|
+3. **集成验证**:
|
|
|
+ - P0-2: `curl -X POST localhost:8081/api/v1/payment/notify/alipay -d '...'` → 响应为纯文本
|
|
|
+ - P0-5: 用 Python 签发 token → 调 Java 端点 → 200 OK
|
|
|
+ - P1-3: 用租户A token → 调 GET /payment/points → 仅返回A的数据
|
|
|
+4. **Swagger UI**: 打开 `http://localhost:8081/swagger-ui.html` 确认端点路径和响应结构正确
|