diff --git a/docs/06-IMPLEMENTATION/IMPL-001-响应式编程培训方案.md b/docs/06-IMPLEMENTATION/IMPL-001-响应式编程培训方案.md new file mode 100644 index 0000000..840e640 --- /dev/null +++ b/docs/06-IMPLEMENTATION/IMPL-001-响应式编程培训方案.md @@ -0,0 +1,280 @@ +# IMPL-001: 响应式编程培训方案 + +> 文档编号: GYM-IMPL-001 +> 版本: v1.0 +> 日期: 2026-04-05 +> 作者: 张翔 +> 状态: 正式发布 + +--- + +## 文档修订历史 + +| 版本 | 日期 | 作者 | 修订内容 | +|------|------|------|---------| +| v1.0 | 2026-04-05 | 张翔 | 创建响应式编程培训方案 | + +--- + +## 一、需求分析 + +### 1.1 问题背景 + +团队对WebFlux和R2DBC不熟悉,影响开发效率和代码质量。 + +### 1.2 培训目标 + +- 掌握响应式编程核心概念(Mono、Flux、背压) +- 熟练使用Spring WebFlux开发REST API +- 掌握R2DBC进行响应式数据库操作 +- 能够进行响应式应用的性能调优 + +### 1.3 成功标准 + +- 开发效率提升30% +- 代码质量提升50% +- Bug率降低40% +- 团队成员理论考试≥80分 +- 代码审查通过率≥90% + +--- + +## 二、培训方案设计 + +### 2.1 培训大纲 + +#### 第1周:响应式编程基础 + +**培训内容**: +- Reactor核心概念 +- Mono和Flux操作符 +- 背压机制 +- 线程模型 + +**培训方式**:线上课程 + +**考核方式**:理论考试 + +**学习目标**: +- 理解响应式编程基本原理 +- 掌握Mono和Flux的基本操作 +- 理解背压机制的作用 + +--- + +#### 第2-3周:WebFlux实战 + +**培训内容**: +- WebFlux应用架构 +- 路由和处理器 +- 请求验证和异常处理 +- 响应式WebClient + +**培训方式**:编码练习 + +**考核方式**:代码审查 + +**学习目标**: +- 能够使用WebFlux开发REST API +- 掌握路由和处理器的设计 +- 能够处理异常和验证请求 + +--- + +#### 第4周:R2DBC实战 + +**培训内容**: +- R2DBC连接池配置 +- 响应式Repository +- 事务管理 +- 性能优化 + +**培训方式**:编码练习 + +**考核方式**:代码审查 + +**学习目标**: +- 能够使用R2DBC进行数据库操作 +- 掌握响应式事务管理 +- 能够优化数据库性能 + +--- + +#### 第5周:性能调优 + +**培训内容**: +- 响应式流监控 +- 性能测试工具 +- 调优策略 +- 常见问题排查 + +**培训方式**:性能测试 + +**考核方式**:性能报告 + +**学习目标**: +- 能够监控响应式流 +- 掌握性能测试工具 +- 能够进行性能调优 + +--- + +#### 第6周:综合项目 + +**培训内容**: +- 完整项目实战 +- 代码审查 +- 项目答辩 + +**培训方式**:项目实战 + +**考核方式**:项目评审 + +**学习目标**: +- 能够独立完成响应式项目 +- 代码质量达到生产标准 + +--- + +### 2.2 培训资源 + +**官方文档**: +- Spring WebFlux官方文档 +- Project Reactor官方文档 +- R2DBC官方文档 + +**视频课程**: +- Reactor官方教程 +- Spring WebFlux实战课程 + +**实战项目**: +- 健身房管理系统的会员模块 + +--- + +### 2.3 培训方式 + +**线上自学 + 线下辅导**: +- 每周自学时间:10小时 +- 每周集中答疑:2次(每次1小时) +- 编码练习:每周20小时 +- 项目实战:最后2周全职 + +--- + +## 三、考核方案 + +### 3.1 理论考试 + +**考试内容**: +- 响应式编程基础概念 +- WebFlux核心原理 +- R2DBC使用方法 +- 性能调优策略 + +**考试形式**:在线考试 + +**及格标准**:≥80分 + +--- + +### 3.2 代码审查 + +**审查内容**: +- 代码规范性 +- 响应式编程最佳实践 +- 异常处理 +- 性能优化 + +**审查标准**: +- 代码规范符合团队标准 +- 无明显性能问题 +- 异常处理完善 +- 测试覆盖率≥80% + +**通过标准**:审查通过率≥90% + +--- + +### 3.3 项目评审 + +**评审内容**: +- 项目功能完整性 +- 代码质量 +- 性能指标 +- 文档完整性 + +**评审标准**: +- 功能完整且符合需求 +- 代码质量达到生产标准 +- 性能指标达标 +- 文档完整清晰 + +--- + +## 四、实施计划 + +### 4.1 培训时间表 + +| 周次 | 培训内容 | 培训方式 | 考核方式 | 负责人 | +|------|---------|---------|---------|--------| +| 第1周 | 响应式编程基础 | 线上课程 | 理论考试 | 培训讲师 | +| 第2-3周 | WebFlux实战 | 编码练习 | 代码审查 | 培训讲师 | +| 第4周 | R2DBC实战 | 编码练习 | 代码审查 | 培训讲师 | +| 第5周 | 性能调优 | 性能测试 | 性能报告 | 培训讲师 | +| 第6周 | 综合项目 | 项目实战 | 项目评审 | 培训讲师 | + +--- + +### 4.2 资源需求 + +**人力资源**: +- 培训讲师:1人 +- 参训人员:全体后端开发 + +**时间资源**: +- 培训时间:4-6周 +- 每周培训时间:30小时 + +**预算资源**: +- 培训预算:¥10,000 +- 包含:课程费用、讲师费用、材料费用 + +--- + +## 五、验收标准 + +### 5.1 培训验收 + +- [ ] 团队成员通过理论考试(≥80分) +- [ ] 团队成员完成实战项目 +- [ ] 代码审查通过率≥90% + +### 5.2 效果验收 + +- [ ] 开发效率提升30% +- [ ] 代码质量提升50% +- [ ] Bug率降低40% + +--- + +## 六、风险与应对 + +### 6.1 风险识别 + +**风险1:学员基础参差不齐** +- 应对:分层次培训,基础薄弱学员额外辅导 + +**风险2:培训时间冲突** +- 应对:灵活安排培训时间,提供录播课程 + +**风险3:实战项目难度过大** +- 应对:提供项目模板和指导文档 + +--- + +## 七、相关文档 + +- [改进路线图](../05-PLANS/改进路线图.md) +- [EVAL-001-架构合理性评估报告](../03-EVALUATION/EVAL-001-架构合理性评估报告.md) +- [ADR-002-响应式编程选型](../02-ARCHITECTURE/架构决策记录/ADR-002-响应式编程选型.md) diff --git a/docs/06-IMPLEMENTATION/IMPL-002-敏感数据加密存储方案.md b/docs/06-IMPLEMENTATION/IMPL-002-敏感数据加密存储方案.md new file mode 100644 index 0000000..29d937d --- /dev/null +++ b/docs/06-IMPLEMENTATION/IMPL-002-敏感数据加密存储方案.md @@ -0,0 +1,590 @@ +# IMPL-002: 敏感数据加密存储方案 + +> 文档编号: GYM-IMPL-002 +> 版本: v1.0 +> 日期: 2026-04-05 +> 作者: 张翔 +> 状态: 正式发布 + +--- + +## 文档修订历史 + +| 版本 | 日期 | 作者 | 修订内容 | +|------|------|------|---------| +| v1.0 | 2026-04-05 | 张翔 | 创建敏感数据加密存储方案 | + +--- + +## 一、需求分析 + +### 1.1 问题背景 + +会员隐私数据、支付信息等敏感数据未加密存储,存在数据泄露风险。 + +### 1.2 敏感数据识别 + +| 数据类型 | 字段名 | 敏感级别 | 加密要求 | +|---------|--------|---------|---------| +| 会员手机号 | phone | 高 | AES-256-GCM | +| 会员身份证号 | id_card | 高 | AES-256-GCM | +| 会员银行卡号 | bank_card | 高 | AES-256-GCM | +| 支付密码 | payment_password | 高 | BCrypt | +| 会员地址 | address | 中 | AES-256-GCM | + +### 1.3 加密要求 + +- 加密算法:AES-256-GCM +- 密钥长度:256位 +- 加密模式:GCM(提供认证加密) +- 密钥管理:环境变量 + 密钥管理服务 + +### 1.4 成功标准 + +- 数据安全性提升100% +- 合规性提升 +- 加密/解密性能≤10ms +- 数据迁移成功率100% + +--- + +## 二、技术方案设计 + +### 2.1 加密算法选择 + +**AES-256-GCM优势**: +- ✅ 安全性高:256位密钥长度 +- ✅ 认证加密:GCM模式提供数据完整性验证 +- ✅ 性能优秀:硬件加速支持 +- ✅ 广泛应用:业界标准算法 + +**加密流程**: +``` +明文 → 生成IV → AES-GCM加密 → Base64编码 → 密文 +密文 → Base64解码 → AES-GCM解密 → 明文 +``` + +--- + +### 2.2 密钥管理方案 + +#### 开发环境 + +**方案**:环境变量存储密钥 + +**配置**: +```bash +export ENCRYPTION_KEY=your-256-bit-key-here +export ENCRYPTION_IV=your-initialization-vector +``` + +**优势**: +- 简单易用 +- 不易泄露到代码库 + +--- + +#### 测试环境 + +**方案**:环境变量 + 配置文件加密 + +**配置**: +```yaml +encryption: + key: ${ENCRYPTION_KEY} + iv: ${ENCRYPTION_IV} + enabled: true +``` + +**优势**: +- 配置灵活 +- 支持多环境 + +--- + +#### 生产环境 + +**方案**:密钥管理服务(如阿里云KMS) + +**架构**: +``` +应用启动 → 从KMS获取密钥 → 缓存到内存 → 使用密钥加密/解密 +``` + +**优势**: +- 密钥集中管理 +- 密钥自动轮换 +- 审计日志完整 + +--- + +### 2.3 数据迁移方案 + +#### 迁移步骤 + +**步骤1:创建加密字段** +```sql +ALTER TABLE member ADD COLUMN phone_encrypted VARCHAR(255); +ALTER TABLE member ADD COLUMN id_card_encrypted VARCHAR(255); +ALTER TABLE member ADD COLUMN bank_card_encrypted VARCHAR(255); +``` + +**步骤2:批量加密现有数据** +```java +@Transactional +public void migrateData() { + List members = memberRepository.findAll(); + + for (Member member : members) { + if (member.getPhone() != null) { + String encrypted = encryptionUtil.encrypt(member.getPhone()); + member.setPhoneEncrypted(encrypted); + } + // 其他字段类似处理 + } + + memberRepository.saveAll(members); +} +``` + +**步骤3:验证加密数据正确性** +```java +public void verifyEncryption() { + List members = memberRepository.findAll(); + + for (Member member : members) { + if (member.getPhoneEncrypted() != null) { + String decrypted = encryptionUtil.decrypt(member.getPhoneEncrypted()); + // 验证解密后的数据与原数据一致 + assert decrypted.equals(member.getPhone()); + } + } +} +``` + +**步骤4:删除明文字段** +```sql +ALTER TABLE member DROP COLUMN phone; +ALTER TABLE member DROP COLUMN id_card; +ALTER TABLE member DROP COLUMN bank_card; +``` + +**步骤5:重命名字段** +```sql +ALTER TABLE member CHANGE COLUMN phone_encrypted phone VARCHAR(255); +ALTER TABLE member CHANGE COLUMN id_card_encrypted id_card VARCHAR(255); +ALTER TABLE member CHANGE COLUMN bank_card_encrypted bank_card VARCHAR(255); +``` + +--- + +## 三、代码结构设计 + +### 3.1 包结构 + +``` +com.gym.manage.security/ +├── encryption/ +│ ├── EncryptionUtil.java # 加密工具类 +│ ├── KeyManager.java # 密钥管理器 +│ └── EncryptionConfig.java # 加密配置 +├── converter/ +│ ├── PhoneEncryptConverter.java # 手机号加密转换器 +│ ├── IdCardEncryptConverter.java # 身份证号加密转换器 +│ └── BankCardEncryptConverter.java # 银行卡号加密转换器 +└── aspect/ + └── EncryptionAspect.java # 加密切面 +``` + +--- + +### 3.2 核心类设计 + +#### EncryptionUtil + +```java +@Component +public class EncryptionUtil { + + private static final String ALGORITHM = "AES/GCM/NoPadding"; + private static final int GCM_IV_LENGTH = 12; + private static final int GCM_TAG_LENGTH = 128; + + @Autowired + private KeyManager keyManager; + + /** + * 加密 + * @param plaintext 明文 + * @return 密文(Base64编码) + */ + public String encrypt(String plaintext) { + try { + SecretKey key = keyManager.getKey(); + byte[] iv = generateIV(); + + Cipher cipher = Cipher.getInstance(ALGORITHM); + GCMParameterSpec spec = new GCMParameterSpec(GCM_TAG_LENGTH, iv); + cipher.init(Cipher.ENCRYPT_MODE, key, spec); + + byte[] ciphertext = cipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8)); + + // IV + Ciphertext + ByteBuffer buffer = ByteBuffer.allocate(iv.length + ciphertext.length); + buffer.put(iv); + buffer.put(ciphertext); + + return Base64.getEncoder().encodeToString(buffer.array()); + + } catch (Exception e) { + throw new EncryptionException("加密失败", e); + } + } + + /** + * 解密 + * @param ciphertext 密文(Base64编码) + * @return 明文 + */ + public String decrypt(String ciphertext) { + try { + SecretKey key = keyManager.getKey(); + byte[] decoded = Base64.getDecoder().decode(ciphertext); + + ByteBuffer buffer = ByteBuffer.wrap(decoded); + byte[] iv = new byte[GCM_IV_LENGTH]; + buffer.get(iv); + byte[] encrypted = new byte[buffer.remaining()]; + buffer.get(encrypted); + + Cipher cipher = Cipher.getInstance(ALGORITHM); + GCMParameterSpec spec = new GCMParameterSpec(GCM_TAG_LENGTH, iv); + cipher.init(Cipher.DECRYPT_MODE, key, spec); + + byte[] plaintext = cipher.doFinal(encrypted); + return new String(plaintext, StandardCharsets.UTF_8); + + } catch (Exception e) { + throw new EncryptionException("解密失败", e); + } + } + + private byte[] generateIV() { + byte[] iv = new byte[GCM_IV_LENGTH]; + new SecureRandom().nextBytes(iv); + return iv; + } +} +``` + +#### KeyManager + +```java +@Component +public class KeyManager { + + @Value("${encryption.key}") + private String keyString; + + private SecretKey cachedKey; + + /** + * 获取加密密钥 + * @return SecretKey + */ + public SecretKey getKey() { + if (cachedKey == null) { + cachedKey = generateKey(keyString); + } + return cachedKey; + } + + private SecretKey generateKey(String keyString) { + byte[] keyBytes = keyString.getBytes(StandardCharsets.UTF_8); + return new SecretKeySpec(keyBytes, "AES"); + } +} +``` + +#### PhoneEncryptConverter + +```java +@Component +@Converter(autoApply = true) +public class PhoneEncryptConverter implements AttributeConverter { + + @Autowired + private EncryptionUtil encryptionUtil; + + @Override + public String convertToDatabaseColumn(String attribute) { + if (attribute == null) { + return null; + } + return encryptionUtil.encrypt(attribute); + } + + @Override + public String convertToEntityAttribute(String dbData) { + if (dbData == null) { + return null; + } + return encryptionUtil.decrypt(dbData); + } +} +``` + +--- + +## 四、测试方案 + +### 4.1 单元测试 + +#### 加密/解密功能测试 + +```java +@SpringBootTest +public class EncryptionUtilTest { + + @Autowired + private EncryptionUtil encryptionUtil; + + @Test + public void testEncryptAndDecrypt() { + String plaintext = "13800138000"; + + // 加密 + String ciphertext = encryptionUtil.encrypt(plaintext); + assertNotNull(ciphertext); + assertNotEquals(plaintext, ciphertext); + + // 解密 + String decrypted = encryptionUtil.decrypt(ciphertext); + assertEquals(plaintext, decrypted); + } + + @Test + public void testEncryptSamePlaintextDifferentCiphertext() { + String plaintext = "13800138000"; + + String ciphertext1 = encryptionUtil.encrypt(plaintext); + String ciphertext2 = encryptionUtil.encrypt(plaintext); + + // 相同明文,不同密文(因为IV不同) + assertNotEquals(ciphertext1, ciphertext2); + + // 但都能正确解密 + assertEquals(plaintext, encryptionUtil.decrypt(ciphertext1)); + assertEquals(plaintext, encryptionUtil.decrypt(ciphertext2)); + } + + @Test + public void testEncryptNull() { + String ciphertext = encryptionUtil.encrypt(null); + assertNull(ciphertext); + } + + @Test + public void testDecryptInvalidCiphertext() { + assertThrows(EncryptionException.class, () -> { + encryptionUtil.decrypt("invalid-ciphertext"); + }); + } +} +``` + +#### 密钥管理测试 + +```java +@SpringBootTest +public class KeyManagerTest { + + @Autowired + private KeyManager keyManager; + + @Test + public void testGetKey() { + SecretKey key = keyManager.getKey(); + assertNotNull(key); + assertEquals("AES", key.getAlgorithm()); + assertEquals(32, key.getEncoded().length); // 256位 = 32字节 + } + + @Test + public void testKeyCache() { + SecretKey key1 = keyManager.getKey(); + SecretKey key2 = keyManager.getKey(); + + // 密钥应该被缓存 + assertSame(key1, key2); + } +} +``` + +--- + +### 4.2 集成测试 + +#### 数据库存储加密测试 + +```java +@SpringBootTest +@Transactional +public class MemberEncryptionTest { + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private EncryptionUtil encryptionUtil; + + @Test + public void testSaveEncryptedPhone() { + Member member = new Member(); + member.setPhone("13800138000"); + + Member saved = memberRepository.save(member); + + // 从数据库直接查询(绕过JPA转换器) + String encryptedPhone = jdbcTemplate.queryForObject( + "SELECT phone FROM member WHERE id = ?", + String.class, + saved.getId() + ); + + // 验证数据库中存储的是加密后的数据 + assertNotNull(encryptedPhone); + assertNotEquals("13800138000", encryptedPhone); + + // 验证可以正确解密 + String decrypted = encryptionUtil.decrypt(encryptedPhone); + assertEquals("13800138000", decrypted); + } + + @Test + public void testReadDecryptedPhone() { + // 保存会员 + Member member = new Member(); + member.setPhone("13800138000"); + Member saved = memberRepository.save(member); + + // 读取会员 + Member found = memberRepository.findById(saved.getId()).orElse(null); + + // 验证读取的是解密后的数据 + assertNotNull(found); + assertEquals("13800138000", found.getPhone()); + } +} +``` + +--- + +### 4.3 性能测试 + +```java +@SpringBootTest +public class EncryptionPerformanceTest { + + @Autowired + private EncryptionUtil encryptionUtil; + + @Test + public void testEncryptPerformance() { + String plaintext = "13800138000"; + int iterations = 1000; + + long startTime = System.currentTimeMillis(); + + for (int i = 0; i < iterations; i++) { + encryptionUtil.encrypt(plaintext); + } + + long endTime = System.currentTimeMillis(); + long avgTime = (endTime - startTime) / iterations; + + System.out.println("平均加密时间: " + avgTime + "ms"); + assertTrue(avgTime < 10, "加密时间应小于10ms"); + } + + @Test + public void testDecryptPerformance() { + String plaintext = "13800138000"; + String ciphertext = encryptionUtil.encrypt(plaintext); + int iterations = 1000; + + long startTime = System.currentTimeMillis(); + + for (int i = 0; i < iterations; i++) { + encryptionUtil.decrypt(ciphertext); + } + + long endTime = System.currentTimeMillis(); + long avgTime = (endTime - startTime) / iterations; + + System.out.println("平均解密时间: " + avgTime + "ms"); + assertTrue(avgTime < 10, "解密时间应小于10ms"); + } +} +``` + +--- + +## 五、实施步骤 + +| 步骤 | 任务 | 负责人 | 完成时间 | 验收标准 | +|------|------|--------|---------|---------| +| 1 | 识别敏感数据字段 | 后端开发 | 1天 | 敏感数据字段清单 | +| 2 | 设计加密方案 | 架构师 | 1天 | 加密方案文档 | +| 3 | 实现加密工具类 | 后端开发 | 2天 | 单元测试通过 | +| 4 | 实现JPA转换器 | 后端开发 | 1天 | 单元测试通过 | +| 5 | 数据迁移脚本 | 后端开发 | 1天 | 迁移脚本完成 | +| 6 | 测试验证 | 测试工程师 | 1天 | 所有测试通过 | + +--- + +## 六、验收标准 + +### 6.1 功能验收 + +- [ ] 敏感数据加密存储 +- [ ] 数据库中无明文敏感数据 +- [ ] 加密数据可正确解密 +- [ ] 通过安全审计 + +### 6.2 性能验收 + +- [ ] 加密/解密性能≤10ms +- [ ] 数据迁移成功率100% + +### 6.3 安全验收 + +- [ ] 密钥管理安全 +- [ ] 无密钥泄露风险 +- [ ] 符合数据安全合规要求 + +--- + +## 七、风险与应对 + +### 7.1 风险识别 + +**风险1:密钥泄露** +- 应对:使用密钥管理服务,定期轮换密钥 + +**风险2:性能影响** +- 应对:使用硬件加速,优化加密算法 + +**风险3:数据迁移失败** +- 应对:备份原数据,分批迁移,验证后删除 + +**风险4:解密失败** +- 应对:保存原始密文,提供手动解密工具 + +--- + +## 八、相关文档 + +- [改进路线图](../05-PLANS/改进路线图.md) +- [EVAL-003-安全性与容错能力评估报告](../03-EVALUATION/EVAL-003-安全性与容错能力评估报告.md) +- [SEC-安全设计](../02-ARCHITECTURE/技术架构/SEC-安全设计.md) diff --git a/docs/06-IMPLEMENTATION/IMPL-003-预约高峰期性能优化方案.md b/docs/06-IMPLEMENTATION/IMPL-003-预约高峰期性能优化方案.md new file mode 100644 index 0000000..e357f7a --- /dev/null +++ b/docs/06-IMPLEMENTATION/IMPL-003-预约高峰期性能优化方案.md @@ -0,0 +1,654 @@ +# IMPL-003: 预约高峰期性能优化方案 + +> 文档编号: GYM-IMPL-003 +> 版本: v1.0 +> 日期: 2026-04-05 +> 作者: 张翔 +> 状态: 正式发布 + +--- + +## 文档修订历史 + +| 版本 | 日期 | 作者 | 修订内容 | +|------|------|------|---------| +| v1.0 | 2026-04-05 | 张翔 | 创建预约高峰期性能优化方案 | + +--- + +## 一、需求分析 + +### 1.1 问题背景 + +预约高峰期QPS仅500-1000,距离目标2000+差距较大,影响用户体验和业务转化。 + +### 1.2 性能问题分析 + +#### 当前性能指标 + +| 指标 | 目标值 | 实际值 | 差距 | +|------|-------|-------|------| +| QPS | 2000+ | 500-1000 | 4倍 | +| 响应时间(P99) | ≤200ms | 600-1000ms | 5倍 | +| 成功率 | ≥99% | 95-97% | 2-4% | + +#### 性能瓶颈识别 + +**瓶颈1:数据库查询慢** +- 缺少索引 +- 全表扫描 +- 慢SQL + +**瓶颈2:无缓存机制** +- 每次查询都访问数据库 +- 热点数据未缓存 +- 缓存命中率低 + +**瓶颈3:同步阻塞** +- 预约高峰期并发处理能力不足 +- 线程池满载 +- 响应时间过长 + +**瓶颈4:缺少消息队列** +- 无法削峰填谷 +- 流量直接冲击数据库 +- 系统稳定性差 + +### 1.3 成功标准 + +- QPS≥2000 +- 响应时间(P99)≤200ms +- 成功率≥99% +- 缓存命中率≥80% +- 数据库主从延迟≤1秒 +- 消息队列无积压 + +--- + +## 二、技术方案设计 + +### 2.1 优化策略组合 + +#### 策略1:引入Redis缓存 + +**缓存对象**: + +| 缓存对象 | TTL | 缓存键格式 | 说明 | +|---------|-----|-----------|------| +| 课程信息 | 1小时 | course:{id} | 课程详情 | +| 会员信息 | 30分钟 | member:{id} | 会员信息 | +| 教练信息 | 1小时 | coach:{id} | 教练信息 | +| 预约名额 | 5分钟 | reservation:quota:{courseId}:{date} | 剩余名额 | + +**缓存策略**: + +``` +Cache-Aside模式: +1. 读取时先查缓存 +2. 缓存未命中则查数据库 +3. 查询结果写入缓存 +4. 写入时更新缓存 +``` + +**缓存架构**: + +``` +应用层 → Redis缓存 → 数据库 + ↓ + 本地缓存(可选) +``` + +--- + +#### 策略2:数据库读写分离 + +**架构设计**: + +``` +应用层 + ↓ +ShardingSphere-JDBC(路由层) + ↓ ↓ +主库(写) 从库(读) +``` + +**实现方案**: + +**主库**:处理写入操作 +- INSERT +- UPDATE +- DELETE + +**从库**:处理读取操作 +- SELECT + +**主从同步**:半同步模式 +- 主库写入后等待至少一个从库确认 +- 保证数据一致性 + +--- + +#### 策略3:引入消息队列削峰 + +**消息队列选型**:RabbitMQ + +**削峰方案**: + +``` +预约请求流程: +1. 用户发起预约请求 +2. 请求写入消息队列 +3. 立即返回"预约中"状态 +4. 后台消费者异步处理 +5. 处理完成后通知用户 +``` + +**消息队列架构**: + +``` +生产者 → Exchange → Queue → 消费者 + ↓ + 死信队列(失败重试) +``` + +**流量控制**: + +- 消费速率:2000 TPS +- 队列容量:10000条消息 +- 超出容量:拒绝请求 + +--- + +### 2.2 技术选型 + +| 组件 | 技术选型 | 版本 | 说明 | +|------|---------|------|------| +| 缓存 | Redis | 6.2+ | 高性能缓存 | +| 数据库中间件 | ShardingSphere-JDBC | 5.3+ | 读写分离路由 | +| 消息队列 | RabbitMQ | 3.9+ | 削峰填谷 | + +--- + +## 三、代码结构设计 + +### 3.1 缓存层设计 + +#### 包结构 + +``` +com.gym.manage.cache/ +├── config/ +│ ├── RedisConfig.java # Redis配置 +│ └── CacheConfig.java # 缓存配置 +├── service/ +│ ├── CacheService.java # 缓存服务接口 +│ └── CacheServiceImpl.java # 缓存服务实现 +└── aspect/ + └── CacheAspect.java # 缓存切面 +``` + +#### 核心类设计 + +**RedisConfig**: + +```java +@Configuration +public class RedisConfig { + + @Bean + public RedisTemplate redisTemplate( + RedisConnectionFactory factory + ) { + RedisTemplate template = new RedisTemplate<>(); + template.setConnectionFactory(factory); + + // 使用Jackson序列化 + Jackson2JsonRedisSerializer serializer = + new Jackson2JsonRedisSerializer<>(Object.class); + + template.setKeySerializer(new StringRedisSerializer()); + template.setValueSerializer(serializer); + template.setHashKeySerializer(new StringRedisSerializer()); + template.setHashValueSerializer(serializer); + + return template; + } +} +``` + +**CacheService**: + +```java +@Service +public class CacheServiceImpl implements CacheService { + + @Autowired + private RedisTemplate redisTemplate; + + @Override + public T get(String key, Class type) { + Object value = redisTemplate.opsForValue().get(key); + return value != null ? (T) value : null; + } + + @Override + public void set(String key, Object value, Duration ttl) { + redisTemplate.opsForValue().set(key, value, ttl); + } + + @Override + public void delete(String key) { + redisTemplate.delete(key); + } + + @Override + public boolean hasKey(String key) { + return Boolean.TRUE.equals(redisTemplate.hasKey(key)); + } +} +``` + +**CacheAspect**: + +```java +@Aspect +@Component +public class CacheAspect { + + @Autowired + private CacheService cacheService; + + @Around("@annotation(cacheable)") + public Object around(ProceedingJoinPoint pjp, Cacheable cacheable) + throws Throwable { + + String key = generateKey(cacheable.key(), pjp.getArgs()); + + // 先查缓存 + Object cached = cacheService.get(key, cacheable.type()); + if (cached != null) { + return cached; + } + + // 缓存未命中,执行方法 + Object result = pjp.proceed(); + + // 写入缓存 + if (result != null) { + cacheService.set(key, result, Duration.ofMinutes(cacheable.ttl())); + } + + return result; + } + + private String generateKey(String pattern, Object[] args) { + // 生成缓存键 + return String.format(pattern, args); + } +} +``` + +--- + +### 3.2 数据库层设计 + +#### 包结构 + +``` +com.gym.manage.datasource/ +├── config/ +│ ├── MasterSlaveConfig.java # 主从配置 +│ └── ShardingConfig.java # 分片配置 +├── routing/ +│ ├── DynamicDataSource.java # 动态数据源 +│ └── DataSourceRouter.java # 数据源路由 +└── annotation/ + └── ReadOnly.java # 只读注解 +``` + +#### 核心类设计 + +**MasterSlaveConfig**: + +```java +@Configuration +public class MasterSlaveConfig { + + @Bean + @ConfigurationProperties(prefix = "spring.datasource.master") + public DataSource masterDataSource() { + return DataSourceBuilder.create().build(); + } + + @Bean + @ConfigurationProperties(prefix = "spring.datasource.slave") + public DataSource slaveDataSource() { + return DataSourceBuilder.create().build(); + } + + @Bean + public DynamicDataSource dynamicDataSource( + @Qualifier("masterDataSource") DataSource master, + @Qualifier("slaveDataSource") DataSource slave + ) { + Map targetDataSources = new HashMap<>(); + targetDataSources.put("master", master); + targetDataSources.put("slave", slave); + + DynamicDataSource dynamicDataSource = new DynamicDataSource(); + dynamicDataSource.setDefaultTargetDataSource(master); + dynamicDataSource.setTargetDataSources(targetDataSources); + + return dynamicDataSource; + } +} +``` + +**DynamicDataSource**: + +```java +public class DynamicDataSource extends AbstractRoutingDataSource { + + private static final ThreadLocal CONTEXT_HOLDER = new ThreadLocal<>(); + + @Override + protected Object determineCurrentLookupKey() { + return CONTEXT_HOLDER.get(); + } + + public static void setMaster() { + CONTEXT_HOLDER.set("master"); + } + + public static void setSlave() { + CONTEXT_HOLDER.set("slave"); + } + + public static void clear() { + CONTEXT_HOLDER.remove(); + } +} +``` + +**DataSourceRouter**: + +```java +@Aspect +@Component +@Order(Ordered.HIGHEST_PRECEDENCE) +public class DataSourceRouter { + + @Around("@annotation(readOnly)") + public Object route(ProceedingJoinPoint pjp, ReadOnly readOnly) + throws Throwable { + + try { + DynamicDataSource.setSlave(); + return pjp.proceed(); + } finally { + DynamicDataSource.clear(); + } + } +} +``` + +**ReadOnly注解**: + +```java +@Target({ElementType.METHOD, ElementType.TYPE}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface ReadOnly { +} +``` + +--- + +### 3.3 消息队列层设计 + +#### 包结构 + +``` +com.gym.manage.mq/ +├── config/ +│ └── RabbitMQConfig.java # RabbitMQ配置 +├── producer/ +│ └── ReservationProducer.java # 预约生产者 +├── consumer/ +│ └── ReservationConsumer.java # 预约消费者 +└── message/ + └── ReservationMessage.java # 预约消息 +``` + +#### 核心类设计 + +**RabbitMQConfig**: + +```java +@Configuration +public class RabbitMQConfig { + + public static final String EXCHANGE = "reservation.exchange"; + public static final String QUEUE = "reservation.queue"; + public static final String ROUTING_KEY = "reservation.create"; + + @Bean + public DirectExchange exchange() { + return new DirectExchange(EXCHANGE); + } + + @Bean + public Queue queue() { + return QueueBuilder.durable(QUEUE) + .withArgument("x-message-ttl", 60000) // 消息TTL: 1分钟 + .withArgument("x-dead-letter-exchange", "reservation.dlx") + .build(); + } + + @Bean + public Binding binding() { + return BindingBuilder.bind(queue()) + .to(exchange()) + .with(ROUTING_KEY); + } +} +``` + +**ReservationProducer**: + +```java +@Service +public class ReservationProducer { + + @Autowired + private RabbitTemplate rabbitTemplate; + + public void sendReservation(ReservationMessage message) { + rabbitTemplate.convertAndSend( + RabbitMQConfig.EXCHANGE, + RabbitMQConfig.ROUTING_KEY, + message + ); + } +} +``` + +**ReservationConsumer**: + +```java +@Service +public class ReservationConsumer { + + @Autowired + private ReservationService reservationService; + + @RabbitListener(queues = RabbitMQConfig.QUEUE) + public void handleReservation(ReservationMessage message) { + try { + // 处理预约 + reservationService.processReservation(message); + + } catch (Exception e) { + // 异常处理,消息进入死信队列 + throw new AmqpRejectAndDontRequeueException(e); + } + } +} +``` + +--- + +## 四、测试方案 + +### 4.1 性能测试 + +#### 测试工具 + +- JMeter:压力测试 +- Gatling:性能测试 +- Prometheus + Grafana:监控 + +#### 测试场景 + +**场景1:预约高峰期模拟** + +```scala +scenario("预约高峰期") + .exec(http("预约课程") + .post("/api/reservations") + .body(StringBody("""{"courseId":1,"memberId":1}""")) + .check(status.is(200)) + ) + .inject( + rampUsersPerSec(100) to 2000 during (300 seconds) + ) + .protocols(httpProtocol) +``` + +**场景2:缓存命中率测试** + +```java +@Test +public void testCacheHitRate() { + int totalRequests = 1000; + int cacheHits = 0; + + for (int i = 0; i < totalRequests; i++) { + Course course = courseService.getCourseById(1L); + if (cacheService.hasKey("course:1")) { + cacheHits++; + } + } + + double hitRate = (double) cacheHits / totalRequests; + System.out.println("缓存命中率: " + (hitRate * 100) + "%"); + assertTrue(hitRate >= 0.8, "缓存命中率应≥80%"); +} +``` + +--- + +### 4.2 压力测试 + +#### 测试步骤 + +1. 逐步增加并发数(500 → 1000 → 2000 → 3000) +2. 监控系统资源(CPU、内存、网络) +3. 记录性能指标(QPS、响应时间、成功率) +4. 识别系统极限 + +#### 性能指标 + +| 并发数 | QPS | 响应时间(P99) | 成功率 | CPU利用率 | 内存利用率 | +|--------|-----|--------------|--------|----------|-----------| +| 500 | 500 | 100ms | 99% | 30% | 50% | +| 1000 | 1000 | 150ms | 99% | 50% | 60% | +| 2000 | 2000 | 200ms | 99% | 70% | 70% | +| 3000 | 2500 | 300ms | 95% | 90% | 80% | + +--- + +### 4.3 稳定性测试 + +#### 测试步骤 + +1. 持续运行2小时 +2. 监控内存泄漏 +3. 监控GC频率 +4. 记录系统稳定性指标 + +#### 稳定性指标 + +- 内存占用稳定 +- GC频率正常 +- 无内存泄漏 +- 无死锁 + +--- + +## 五、实施步骤 + +| 步骤 | 任务 | 负责人 | 完成时间 | 验收标准 | +|------|------|--------|---------|---------| +| 1 | Redis环境搭建 | 运维工程师 | 1天 | Redis服务正常运行 | +| 2 | 实现缓存服务 | 后端开发 | 2天 | 单元测试通过 | +| 3 | 数据库主从配置 | 运维工程师 | 1天 | 主从同步正常 | +| 4 | 实现读写分离 | 后端开发 | 3天 | 集成测试通过 | +| 5 | RabbitMQ环境搭建 | 运维工程师 | 1天 | RabbitMQ服务正常运行 | +| 6 | 实现消息队列 | 后端开发 | 2天 | 单元测试通过 | +| 7 | 性能测试 | 测试工程师 | 2天 | 性能指标达标 | +| 8 | 灰度发布 | 运维工程师 | 1天 | 灰度发布成功 | + +--- + +## 六、验收标准 + +### 6.1 性能验收 + +- [ ] QPS≥2000 +- [ ] 响应时间(P99)≤200ms +- [ ] 成功率≥99% + +### 6.2 缓存验收 + +- [ ] 缓存命中率≥80% +- [ ] 缓存穿透防护有效 +- [ ] 缓存雪崩防护有效 + +### 6.3 数据库验收 + +- [ ] 数据库主从延迟≤1秒 +- [ ] 读写分离正常 +- [ ] 数据一致性保证 + +### 6.4 消息队列验收 + +- [ ] 消息队列无积压 +- [ ] 消息不丢失 +- [ ] 消费速率达标 + +--- + +## 七、风险与应对 + +### 7.1 风险识别 + +**风险1:缓存穿透** +- 应对:布隆过滤器 + 空值缓存 + +**风险2:缓存雪崩** +- 应对:随机过期时间 + 多级缓存 + +**风险3:主从延迟** +- 应对:关键业务读主库 + 半同步复制 + +**风险4:消息队列积压** +- 应对:监控告警 + 动态扩容消费者 + +--- + +## 八、相关文档 + +- [改进路线图](../05-PLANS/改进路线图.md) +- [EVAL-002-性能与可扩展性评估报告](../03-EVALUATION/EVAL-002-性能与可扩展性评估报告.md) +- [ADR-002-响应式编程选型](../02-ARCHITECTURE/架构决策记录/ADR-002-响应式编程选型.md) diff --git a/docs/06-IMPLEMENTATION/IMPL-004-支付接口幂等性校验方案.md b/docs/06-IMPLEMENTATION/IMPL-004-支付接口幂等性校验方案.md new file mode 100644 index 0000000..350d50f --- /dev/null +++ b/docs/06-IMPLEMENTATION/IMPL-004-支付接口幂等性校验方案.md @@ -0,0 +1,741 @@ +# IMPL-004: 支付接口幂等性校验方案 + +> 文档编号: GYM-IMPL-004 +> 版本: v1.0 +> 日期: 2026-04-05 +> 作者: 张翔 +> 状态: 正式发布 + +--- + +## 文档修订历史 + +| 版本 | 日期 | 作者 | 修订内容 | +|------|------|------|---------| +| v1.0 | 2026-04-05 | 张翔 | 创建支付接口幂等性校验方案 | + +--- + +## 一、需求分析 + +### 1.1 问题背景 + +支付接口缺少幂等性校验,可能导致: +- 用户重复点击支付按钮,产生多笔订单 +- 网络超时重试,导致重复扣款 +- 支付回调重复通知,导致订单状态异常 + +### 1.2 影响范围 + +**用户体验**: +- 重复扣款导致用户投诉 +- 订单状态不一致 + +**财务风险**: +- 资金对账困难 +- 退款流程复杂 + +**业务风险**: +- 订单状态不一致 +- 数据完整性问题 + +### 1.3 成功标准 + +- 支付接口幂等性覆盖率100% +- 通过重复支付测试 +- 通过并发支付测试 +- 幂等检查性能≤10ms + +--- + +## 二、技术方案设计 + +### 2.1 幂等性实现方案 + +#### 方案架构 + +``` +支付请求流程: +1. 前端生成唯一订单号 +2. 后端检查订单号是否已存在 +3. 不存在则创建支付流水 +4. 调用第三方支付 +5. 支付回调处理(幂等) +6. 更新订单状态(幂等) +``` + +#### 核心机制 + +**机制1:唯一订单号** +- 格式:UUID + 时间戳 + 业务标识 +- 示例:PAY-20260405-UUID-001 +- 作用:全局唯一标识 + +**机制2:支付流水表** +- 记录所有支付请求 +- 字段:流水ID、订单ID、支付单号、金额、状态、请求号 +- 作用:幂等性保证 + +**机制3:分布式锁** +- 实现:Redis分布式锁 +- 锁键:payment:lock:{requestNo} +- 作用:防止并发重复支付 + +**机制4:状态机** +- 订单状态流转控制 +- 状态:待支付、支付中、支付成功、支付失败、取消支付 +- 作用:状态一致性保证 + +--- + +### 2.2 支付状态机 + +#### 状态定义 + +```java +public enum PaymentState { + PENDING, // 待支付 + PAYING, // 支付中 + SUCCESS, // 支付成功 + FAILED, // 支付失败 + CANCELLED // 取消支付 +} +``` + +#### 事件定义 + +```java +public enum PaymentEvent { + PAY, // 发起支付 + SUCCESS, // 支付成功 + FAIL, // 支付失败 + CANCEL // 取消支付 +} +``` + +#### 状态转换规则 + +``` +状态转换图: + +待支付 ──PAY──> 支付中 ──SUCCESS──> 支付成功 + │ │ + │ └──FAIL──> 支付失败 + │ + └──CANCEL──> 取消支付 + +转换规则: +- 待支付 → 支付中:发起支付 +- 支付中 → 支付成功:支付成功回调 +- 支付中 → 支付失败:支付失败回调 +- 待支付 → 取消支付:用户取消 +``` + +--- + +## 三、代码结构设计 + +### 3.1 包结构 + +``` +com.gym.manage.payment/ +├── idempotent/ +│ ├── IdempotentService.java # 幂等性服务接口 +│ ├── IdempotentServiceImpl.java # 幂等性服务实现 +│ └── IdempotentAspect.java # 幂等性切面 +├── statemachine/ +│ ├── PaymentStateMachine.java # 支付状态机 +│ ├── PaymentState.java # 支付状态 +│ └── PaymentEvent.java # 支付事件 +├── entity/ +│ ├── PaymentFlow.java # 支付流水实体 +│ └── PaymentOrder.java # 支付订单实体 +├── repository/ +│ ├── PaymentFlowRepository.java # 支付流水Repository +│ └── PaymentOrderRepository.java # 支付订单Repository +├── service/ +│ ├── PaymentService.java # 支付服务接口 +│ └── PaymentServiceImpl.java # 支付服务实现 +└── controller/ + └── PaymentController.java # 支付控制器 +``` + +--- + +### 3.2 核心类设计 + +#### PaymentFlow实体 + +```java +@Entity +@Table(name = "payment_flow", + uniqueConstraints = @UniqueConstraint(columnNames = "requestNo")) +public class PaymentFlow { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private String flowId; // 流水ID + + @Column(unique = true, nullable = false) + private String requestNo; // 请求号(幂等键) + + @Column(nullable = false) + private String orderId; // 订单ID + + private String paymentNo; // 支付单号 + + @Column(nullable = false, precision = 10, scale = 2) + private BigDecimal amount; // 支付金额 + + @Enumerated(EnumType.STRING) + private PaymentState state; // 支付状态 + + private String channel; // 支付渠道 + + private String errorMessage; // 错误信息 + + @CreatedDate + private LocalDateTime createTime; // 创建时间 + + @LastModifiedDate + private LocalDateTime updateTime; // 更新时间 +} +``` + +#### IdempotentServiceImpl + +```java +@Service +@Transactional +public class IdempotentServiceImpl implements IdempotentService { + + @Autowired + private RedisTemplate redisTemplate; + + @Autowired + private PaymentFlowRepository flowRepository; + + private static final String LOCK_PREFIX = "payment:lock:"; + private static final Duration LOCK_TIMEOUT = Duration.ofMinutes(5); + + @Override + public PaymentFlow checkAndCreate(String requestNo, PaymentRequest request) { + // 1. 分布式锁 + String lockKey = LOCK_PREFIX + requestNo; + Boolean locked = redisTemplate.opsForValue() + .setIfAbsent(lockKey, "1", LOCK_TIMEOUT); + + if (!locked) { + throw new PaymentException("支付处理中,请勿重复提交"); + } + + try { + // 2. 检查流水是否已存在 + PaymentFlow existFlow = flowRepository + .findByRequestNo(requestNo) + .orElse(null); + + if (existFlow != null) { + return existFlow; // 幂等返回 + } + + // 3. 创建新流水 + PaymentFlow flow = new PaymentFlow(); + flow.setRequestNo(requestNo); + flow.setOrderId(request.getOrderId()); + flow.setAmount(request.getAmount()); + flow.setState(PaymentState.PENDING); + flow.setChannel(request.getChannel()); + + return flowRepository.save(flow); + + } finally { + redisTemplate.delete(lockKey); + } + } + + @Override + public PaymentFlow check(String requestNo) { + return flowRepository.findByRequestNo(requestNo).orElse(null); + } +} +``` + +#### PaymentStateMachine + +```java +@Component +public class PaymentStateMachine { + + @Autowired + private PaymentOrderRepository orderRepository; + + /** + * 状态转换(幂等) + */ + @Transactional + public boolean transit(String orderId, PaymentEvent event) { + PaymentOrder order = orderRepository.findById(orderId) + .orElseThrow(() -> new PaymentException("订单不存在")); + + PaymentState currentState = order.getState(); + + // 检查状态转换是否合法 + if (!canTransit(currentState, event)) { + return false; // 幂等返回 + } + + // 执行状态转换 + PaymentState newState = getNextState(currentState, event); + order.setState(newState); + orderRepository.save(order); + + return true; + } + + private boolean canTransit(PaymentState current, PaymentEvent event) { + // 状态转换规则 + switch (current) { + case PENDING: + return event == PaymentEvent.PAY || + event == PaymentEvent.CANCEL; + case PAYING: + return event == PaymentEvent.SUCCESS || + event == PaymentEvent.FAIL; + default: + return false; // 已终态,幂等返回 + } + } + + private PaymentState getNextState(PaymentState current, PaymentEvent event) { + switch (current) { + case PENDING: + if (event == PaymentEvent.PAY) return PaymentState.PAYING; + if (event == PaymentEvent.CANCEL) return PaymentState.CANCELLED; + break; + case PAYING: + if (event == PaymentEvent.SUCCESS) return PaymentState.SUCCESS; + if (event == PaymentEvent.FAIL) return PaymentState.FAILED; + break; + default: + break; + } + return current; + } +} +``` + +#### IdempotentAspect + +```java +@Aspect +@Component +@Order(Ordered.HIGHEST_PRECEDENCE) +public class IdempotentAspect { + + @Autowired + private IdempotentService idempotentService; + + @Around("@annotation(idempotent)") + public Object handleIdempotent( + ProceedingJoinPoint pjp, + Idempotent idempotent + ) throws Throwable { + + // 1. 获取幂等键 + String requestNo = extractRequestNo(pjp); + + // 2. 检查幂等性 + Object result = idempotentService.check(requestNo); + if (result != null) { + return result; // 幂等返回 + } + + // 3. 执行业务逻辑 + return pjp.proceed(); + } + + private String extractRequestNo(ProceedingJoinPoint pjp) { + // 从方法参数中提取requestNo + Object[] args = pjp.getArgs(); + for (Object arg : args) { + if (arg instanceof PaymentRequest) { + return ((PaymentRequest) arg).getRequestNo(); + } + } + throw new PaymentException("未找到幂等键"); + } +} +``` + +#### PaymentServiceImpl + +```java +@Service +@Transactional +public class PaymentServiceImpl implements PaymentService { + + @Autowired + private IdempotentService idempotentService; + + @Autowired + private PaymentStateMachine stateMachine; + + @Autowired + private PaymentFlowRepository flowRepository; + + @Autowired + private ThirdPartyPaymentService thirdPartyService; + + @Override + public PaymentResponse pay(PaymentRequest request) { + // 1. 幂等性检查并创建流水 + PaymentFlow flow = idempotentService.checkAndCreate( + request.getRequestNo(), + request + ); + + // 如果流水已存在,直接返回 + if (flow.getState() != PaymentState.PENDING) { + return buildResponse(flow); + } + + // 2. 状态转换:待支付 → 支付中 + stateMachine.transit(flow.getFlowId(), PaymentEvent.PAY); + + try { + // 3. 调用第三方支付 + ThirdPartyPaymentResponse response = thirdPartyService.pay( + flow.getFlowId(), + flow.getAmount() + ); + + // 4. 更新支付单号 + flow.setPaymentNo(response.getPaymentNo()); + flowRepository.save(flow); + + // 5. 返回支付结果 + return buildResponse(flow); + + } catch (Exception e) { + // 6. 支付失败,状态转换 + stateMachine.transit(flow.getFlowId(), PaymentEvent.FAIL); + flow.setErrorMessage(e.getMessage()); + flowRepository.save(flow); + + throw new PaymentException("支付失败", e); + } + } + + @Override + public void handleCallback(PaymentCallback callback) { + // 1. 查询支付流水 + PaymentFlow flow = flowRepository.findById(callback.getFlowId()) + .orElseThrow(() -> new PaymentException("流水不存在")); + + // 2. 幂等性检查:如果已成功,直接返回 + if (flow.getState() == PaymentState.SUCCESS) { + return; // 幂等返回 + } + + // 3. 状态转换 + PaymentEvent event = callback.isSuccess() ? + PaymentEvent.SUCCESS : PaymentEvent.FAIL; + + stateMachine.transit(flow.getFlowId(), event); + + // 4. 更新流水 + flowRepository.save(flow); + } + + private PaymentResponse buildResponse(PaymentFlow flow) { + PaymentResponse response = new PaymentResponse(); + response.setFlowId(flow.getFlowId()); + response.setPaymentNo(flow.getPaymentNo()); + response.setState(flow.getState()); + response.setAmount(flow.getAmount()); + return response; + } +} +``` + +--- + +## 四、测试方案 + +### 4.1 单元测试 + +#### 幂等性服务测试 + +```java +@SpringBootTest +@Transactional +public class IdempotentServiceTest { + + @Autowired + private IdempotentService idempotentService; + + @Autowired + private PaymentFlowRepository flowRepository; + + @Test + public void testCheckAndCreate() { + String requestNo = "REQ-123456"; + PaymentRequest request = new PaymentRequest(); + request.setRequestNo(requestNo); + request.setOrderId("ORDER-001"); + request.setAmount(new BigDecimal("100.00")); + + // 第一次创建 + PaymentFlow flow1 = idempotentService.checkAndCreate(requestNo, request); + assertNotNull(flow1); + assertEquals(PaymentState.PENDING, flow1.getState()); + + // 第二次创建(相同requestNo) + PaymentFlow flow2 = idempotentService.checkAndCreate(requestNo, request); + assertEquals(flow1.getFlowId(), flow2.getFlowId()); // 幂等返回 + } + + @Test + public void testConcurrentCreate() throws InterruptedException { + String requestNo = "REQ-789012"; + int threadCount = 100; + CountDownLatch latch = new CountDownLatch(threadCount); + List flows = Collections.synchronizedList(new ArrayList<>()); + + for (int i = 0; i < threadCount; i++) { + new Thread(() -> { + try { + PaymentFlow flow = idempotentService.checkAndCreate( + requestNo, + new PaymentRequest() + ); + flows.add(flow); + } finally { + latch.countDown(); + } + }).start(); + } + + latch.await(); + + // 验证只创建了一笔流水 + assertEquals(1, flows.stream() + .map(PaymentFlow::getFlowId) + .distinct() + .count()); + } +} +``` + +#### 状态机测试 + +```java +@SpringBootTest +@Transactional +public class PaymentStateMachineTest { + + @Autowired + private PaymentStateMachine stateMachine; + + @Autowired + private PaymentOrderRepository orderRepository; + + @Test + public void testStateTransit() { + // 创建订单 + PaymentOrder order = new PaymentOrder(); + order.setOrderId("ORDER-001"); + order.setState(PaymentState.PENDING); + orderRepository.save(order); + + // 状态转换:待支付 → 支付中 + boolean result1 = stateMachine.transit("ORDER-001", PaymentEvent.PAY); + assertTrue(result1); + + PaymentOrder order1 = orderRepository.findById("ORDER-001").orElse(null); + assertEquals(PaymentState.PAYING, order1.getState()); + + // 状态转换:支付中 → 支付成功 + boolean result2 = stateMachine.transit("ORDER-001", PaymentEvent.SUCCESS); + assertTrue(result2); + + PaymentOrder order2 = orderRepository.findById("ORDER-001").orElse(null); + assertEquals(PaymentState.SUCCESS, order2.getState()); + + // 状态转换:支付成功 → 支付失败(非法转换) + boolean result3 = stateMachine.transit("ORDER-001", PaymentEvent.FAIL); + assertFalse(result3); // 幂等返回 + } +} +``` + +--- + +### 4.2 集成测试 + +#### 完整支付流程测试 + +```java +@SpringBootTest +@Transactional +public class PaymentIntegrationTest { + + @Autowired + private PaymentService paymentService; + + @Autowired + private PaymentFlowRepository flowRepository; + + @Test + public void testCompletePaymentFlow() { + // 1. 发起支付 + PaymentRequest request = new PaymentRequest(); + request.setRequestNo("REQ-001"); + request.setOrderId("ORDER-001"); + request.setAmount(new BigDecimal("100.00")); + + PaymentResponse response = paymentService.pay(request); + assertNotNull(response.getFlowId()); + assertEquals(PaymentState.PAYING, response.getState()); + + // 2. 模拟支付成功回调 + PaymentCallback callback = new PaymentCallback(); + callback.setFlowId(response.getFlowId()); + callback.setSuccess(true); + + paymentService.handleCallback(callback); + + // 3. 验证流水状态 + PaymentFlow flow = flowRepository.findById(response.getFlowId()).orElse(null); + assertEquals(PaymentState.SUCCESS, flow.getState()); + } + + @Test + public void testDuplicatePayment() { + String requestNo = "REQ-002"; + + // 第一次支付 + PaymentRequest request = new PaymentRequest(); + request.setRequestNo(requestNo); + request.setOrderId("ORDER-002"); + request.setAmount(new BigDecimal("100.00")); + + PaymentResponse response1 = paymentService.pay(request); + + // 第二次支付(相同requestNo) + PaymentResponse response2 = paymentService.pay(request); + + // 验证幂等性 + assertEquals(response1.getFlowId(), response2.getFlowId()); + } +} +``` + +--- + +### 4.3 压力测试 + +#### 并发支付测试 + +```java +@SpringBootTest +public class PaymentConcurrencyTest { + + @Autowired + private PaymentService paymentService; + + @Autowired + private PaymentFlowRepository flowRepository; + + @Test + public void testConcurrentPayment() throws InterruptedException { + int threadCount = 100; + CountDownLatch latch = new CountDownLatch(threadCount); + String requestNo = "REQ-CONCURRENT-001"; + + for (int i = 0; i < threadCount; i++) { + new Thread(() -> { + try { + PaymentRequest request = new PaymentRequest(); + request.setRequestNo(requestNo); + request.setOrderId("ORDER-CONCURRENT-001"); + request.setAmount(new BigDecimal("100.00")); + + paymentService.pay(request); + } finally { + latch.countDown(); + } + }).start(); + } + + latch.await(); + + // 验证只创建了一笔支付流水 + List flows = flowRepository.findByRequestNo(requestNo); + assertEquals(1, flows.size()); + } +} +``` + +--- + +## 五、实施步骤 + +| 步骤 | 任务 | 负责人 | 完成时间 | 验收标准 | +|------|------|--------|---------|---------| +| 1 | 设计幂等性方案 | 架构师 | 1天 | 方案文档完成 | +| 2 | 创建支付流水表 | 后端开发 | 1天 | 数据库表创建完成 | +| 3 | 实现幂等性服务 | 后端开发 | 2天 | 单元测试通过 | +| 4 | 实现状态机 | 后端开发 | 1天 | 单元测试通过 | +| 5 | 实现幂等性切面 | 后端开发 | 1天 | 单元测试通过 | +| 6 | 单元测试 | 后端开发 | 1天 | 单元测试通过 | +| 7 | 集成测试 | 测试工程师 | 1天 | 集成测试通过 | +| 8 | 压力测试 | 测试工程师 | 1天 | 性能指标达标 | + +--- + +## 六、验收标准 + +### 6.1 功能验收 + +- [ ] 支付接口幂等性覆盖率100% +- [ ] 通过重复支付测试 +- [ ] 通过并发支付测试 +- [ ] 支付回调幂等处理 + +### 6.2 性能验收 + +- [ ] 幂等检查性能≤10ms +- [ ] 分布式锁获取≤5ms + +### 6.3 安全验收 + +- [ ] 无重复扣款风险 +- [ ] 订单状态一致性保证 + +--- + +## 七、风险与应对 + +### 7.1 风险识别 + +**风险1:分布式锁失效** +- 应对:数据库唯一索引兜底 + +**风险2:状态机死锁** +- 应对:超时自动释放 + 监控告警 + +**风险3:支付流水表过大** +- 应对:定期归档历史流水 + +**风险4:第三方支付重复回调** +- 应对:幂等处理 + 幂等键去重 + +--- + +## 八、相关文档 + +- [改进路线图](../05-PLANS/改进路线图.md) +- [EVAL-003-安全性与容错能力评估报告](../03-EVALUATION/EVAL-003-安全性与容错能力评估报告.md) +- [SEC-安全设计](../02-ARCHITECTURE/技术架构/SEC-安全设计.md)