# 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 alipayNotify(@RequestParam Map params) { String result = notificationService.verifyAndDispatch(params); return Result.ok(result); } // 改后: @PostMapping("/alipay") public ResponseEntity alipayNotify(@RequestParam Map params) { String result = notificationService.verifyAndDispatch(params); return ResponseEntity.ok() .contentType(MediaType.TEXT_PLAIN) .body(result); } ``` 删除 `Result.ok()` 包装,改为 `ResponseEntity` + `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 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 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` 确认端点路径和响应结构正确