|
@@ -0,0 +1,213 @@
|
|
|
|
|
+package com.etotem.cfc.unit;
|
|
|
|
|
+
|
|
|
|
|
+import org.junit.jupiter.api.DisplayName;
|
|
|
|
|
+import org.junit.jupiter.api.Test;
|
|
|
|
|
+
|
|
|
|
|
+import java.io.InputStream;
|
|
|
|
|
+import java.lang.reflect.Field;
|
|
|
|
|
+import java.lang.reflect.Method;
|
|
|
|
|
+import java.nio.charset.StandardCharsets;
|
|
|
|
|
+import java.util.Scanner;
|
|
|
|
|
+
|
|
|
|
|
+import static org.junit.jupiter.api.Assertions.assertNotNull;
|
|
|
|
|
+import static org.junit.jupiter.api.Assertions.assertTrue;
|
|
|
|
|
+import static org.junit.jupiter.api.Assertions.fail;
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * 契约级单测:团队分润支付成功钩子(能力键 {@code team.profit.hook})。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>契约来源(v0.7 §12-T3 + §2.2):
|
|
|
|
|
+ * <blockquote>
|
|
|
|
|
+ * T3 集成(P0):将 <b>6 个订单服务支付成功钩子</b> 接入 {@code ProfitPoolService.settle()},
|
|
|
|
|
+ * 复用现有 {@code CfCommissionService.settle} 调用点,<b>同点位并行调用</b>,不新增支付链路改动;
|
|
|
|
|
+ * 触发条件(§2.2):可分配池 {@code D = P - O - F > 0} 才进入分润流程。
|
|
|
|
|
+ * </blockquote></p>
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>6 个调用点(src/main 现状核实):
|
|
|
|
|
+ * <ul>
|
|
|
|
|
+ * <li>{@code PaymentService} → {@code cfCommissionService.settle(orderId, "package", ...)}</li>
|
|
|
|
|
+ * <li>{@code ProductOrderService} → {@code cfCommissionService.settle(orderId, "product", ...)}</li>
|
|
|
|
|
+ * <li>{@code AssessmentOrderService} → {@code cfCommissionService.settle(orderId, "assessment", ...)}</li>
|
|
|
|
|
+ * <li>{@code MemberSubscriptionService} → {@code settleReferrerOnly(orderId, "subscription", ...)} ×2</li>
|
|
|
|
|
+ * <li>{@code MembershipService} → {@code settleReferrerOnly(orderId, "membership", ...)}</li>
|
|
|
|
|
+ * </ul></p>
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p><b>实现现状</b>:本分支 src/main 中 {@code ProfitPoolService} 类、{@code profit_pool_order} /
|
|
|
|
|
+ * {@code team_share_record} / {@code team_award_record} / {@code service_cost_config} 表均未落地
|
|
|
|
|
+ * (全库 0 引用,schema.sql 无对应建表)。因此本测试以「反射结构契约」探测实现缺失:
|
|
|
|
|
+ * 能编译通过、能运行、断言失败(红),且失败断言原文清晰可贴。</p>
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p><b>GREEN 升级指引</b>:实现者创建 {@code ProfitPoolService}(含 {@code settle} 入口)、
|
|
|
|
|
+ * 建 4 张表与实体、并在 6 个订单服务同点位注入 {@code profitPoolService} 钩子后,本测试自动转绿;
|
|
|
|
|
+ * 届时可将结构探测升级为行为级 mock 验证({@code verify(profitPoolService).settle(...)})。</p>
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p><b>权限流说明</b>:本能力为后端内部服务钩子(支付成功回调 → 分润服务),无用户权限维度,
|
|
|
|
|
+ * 「权限(适用时)」三流中的权限流不适用,以说明性用例标注。</p>
|
|
|
|
|
+ */
|
|
|
|
|
+class TeamProfitHookContractTest {
|
|
|
|
|
+
|
|
|
|
|
+ /** 契约要求接入的分润服务(§12-T3) */
|
|
|
|
|
+ private static final String PROFIT_POOL_SERVICE = "com.etotem.cfc.service.ProfitPoolService";
|
|
|
|
|
+
|
|
|
|
|
+ /** 6 个调用点所在的 5 个订单服务类(MemberSubscriptionService 含 2 个调用点) */
|
|
|
|
|
+ private static final String[] ORDER_SERVICES = {
|
|
|
|
|
+ "com.etotem.cfc.service.PaymentService",
|
|
|
|
|
+ "com.etotem.cfc.service.ProductOrderService",
|
|
|
|
|
+ "com.etotem.cfc.service.AssessmentOrderService",
|
|
|
|
|
+ "com.etotem.cfc.service.MemberSubscriptionService",
|
|
|
|
|
+ "com.etotem.cfc.service.MembershipService"
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ /** §7.1/7.3/7.4/7.5 四张新表 */
|
|
|
|
|
+ private static final String[] PROFIT_TABLES = {
|
|
|
|
|
+ "profit_pool_order", "team_share_record", "team_award_record", "service_cost_config"
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ /** §7.1 profit_pool_order 表唯一键(幂等 §8.3) */
|
|
|
|
|
+ private static final String PROFIT_POOL_UNIQUE_KEY = "uk_order";
|
|
|
|
|
+
|
|
|
|
|
+ // ------------------------------------------------------------------
|
|
|
|
|
+ // 正常流:ProfitPoolService 服务类 + settle 入口应存在
|
|
|
|
|
+ // ------------------------------------------------------------------
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("正常流-1:ProfitPoolService 服务类应存在")
|
|
|
|
|
+ void profitPoolService_classExists() {
|
|
|
|
|
+ assertNotNull(loadClass(PROFIT_POOL_SERVICE),
|
|
|
|
|
+ "[team.profit.hook] ProfitPoolService 类不存在:契约要求 6 个订单服务支付成功钩子"
|
|
|
|
|
+ + "同点位并行调用 ProfitPoolService.settle()(§12-T3)。实现未落地。");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("正常流-2:ProfitPoolService 应提供 settle 入口")
|
|
|
|
|
+ void profitPoolService_hasSettleMethod() {
|
|
|
|
|
+ Class<?> clazz = loadClass(PROFIT_POOL_SERVICE);
|
|
|
|
|
+ if (clazz == null) {
|
|
|
|
|
+ fail("[team.profit.hook] ProfitPoolService 类不存在,无法验证 settle 入口(§12-T3)。");
|
|
|
|
|
+ }
|
|
|
|
|
+ boolean hasSettle = false;
|
|
|
|
|
+ for (Method m : clazz.getDeclaredMethods()) {
|
|
|
|
|
+ if (m.getName().equals("settle")) {
|
|
|
|
|
+ hasSettle = true;
|
|
|
|
|
+ break;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ assertTrue(hasSettle,
|
|
|
|
|
+ "[team.profit.hook] ProfitPoolService 应提供 settle(...) 方法作为支付成功钩子入口(§12-T3)。");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ------------------------------------------------------------------
|
|
|
|
|
+ // 正常流:6 个订单服务应注入 ProfitPoolService 钩子(同点位)
|
|
|
|
|
+ // ------------------------------------------------------------------
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("正常流-3:6 个订单服务应注入 ProfitPoolService 钩子")
|
|
|
|
|
+ void orderServices_injectProfitPoolServiceHook() {
|
|
|
|
|
+ for (String svc : ORDER_SERVICES) {
|
|
|
|
|
+ Class<?> clazz = loadClass(svc);
|
|
|
|
|
+ assertNotNull(clazz, "[team.profit.hook] 订单服务类不存在:" + svc);
|
|
|
|
|
+ boolean injected = false;
|
|
|
|
|
+ for (Field f : clazz.getDeclaredFields()) {
|
|
|
|
|
+ if (PROFIT_POOL_SERVICE.equals(f.getType().getName())) {
|
|
|
|
|
+ injected = true;
|
|
|
|
|
+ break;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ assertTrue(injected,
|
|
|
|
|
+ "[team.profit.hook] " + simpleName(svc)
|
|
|
|
|
+ + " 未注入 ProfitPoolService 钩子:应在 CfCommissionService.settle 调用点"
|
|
|
|
|
+ + " 同点位并行调用 ProfitPoolService.settle()(§12-T3)。");
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ------------------------------------------------------------------
|
|
|
|
|
+ // 边界流:触发条件 §2.2(D>0 才分润)—— profit_pool_order 承载可分配池
|
|
|
|
|
+ // ------------------------------------------------------------------
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("边界流-1:profit_pool_order 实体应存在并承载可分配池 D(poolCent)")
|
|
|
|
|
+ void profitPoolOrder_entityCarriesPool() {
|
|
|
|
|
+ Class<?> poolEntity = loadClass("com.etotem.cfc.entity.ProfitPoolOrder");
|
|
|
|
|
+ if (poolEntity == null) {
|
|
|
|
|
+ fail("[team.profit.hook] ProfitPoolOrder 实体不存在:§7.1 要求 profit_pool_order 表记录"
|
|
|
|
|
+ + "可分配池 D(pool_cent),触发条件 D>0 才分润(§2.2)。");
|
|
|
|
|
+ }
|
|
|
|
|
+ boolean hasPoolCent = false;
|
|
|
|
|
+ for (Field f : poolEntity.getDeclaredFields()) {
|
|
|
|
|
+ if (f.getName().equals("poolCent")) {
|
|
|
|
|
+ hasPoolCent = true;
|
|
|
|
|
+ break;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ assertTrue(hasPoolCent,
|
|
|
|
|
+ "[team.profit.hook] ProfitPoolOrder 实体应含 poolCent 字段(对应 pool_cent,§7.1),"
|
|
|
|
|
+ + "用于判定触发条件 D = P - O - F > 0(§2.2)。");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("边界流-2:profit_pool_order 应含 uk_order 唯一键(幂等 §8.3)")
|
|
|
|
|
+ void profitPoolOrder_hasIdempotentUniqueKey() {
|
|
|
|
|
+ String schema = readSchemaSql();
|
|
|
|
|
+ assertTrue(schema.contains("profit_pool_order"),
|
|
|
|
|
+ "[team.profit.hook] schema.sql 缺少 profit_pool_order 建表(§7.1)。");
|
|
|
|
|
+ assertTrue(schema.contains(PROFIT_POOL_UNIQUE_KEY),
|
|
|
|
|
+ "[team.profit.hook] profit_pool_order 应含 uk_order 唯一键,保证重复回调不重复发放(§8.3)。");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ------------------------------------------------------------------
|
|
|
|
|
+ // 边界流:四张新表应落地 schema.sql
|
|
|
|
|
+ // ------------------------------------------------------------------
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("边界流-3:四张新表应落地 schema.sql(§7.1/7.3/7.4/7.5)")
|
|
|
|
|
+ void schemaSql_containsFourProfitTables() {
|
|
|
|
|
+ String schema = readSchemaSql();
|
|
|
|
|
+ for (String table : PROFIT_TABLES) {
|
|
|
|
|
+ assertTrue(schema.contains(table),
|
|
|
|
|
+ "[team.profit.hook] schema.sql 缺少 " + table + " 建表(§7.1/7.3/7.4/7.5)。");
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ------------------------------------------------------------------
|
|
|
|
|
+ // 权限流(不适用):后端内部服务钩子,无用户权限维度
|
|
|
|
|
+ // ------------------------------------------------------------------
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ @DisplayName("权限流:本能力为后端内部服务钩子,无用户权限维度,权限流不适用")
|
|
|
|
|
+ void permissionFlow_notApplicable() {
|
|
|
|
|
+ // 说明性用例:契约 AC 为「支付成功回调 → 分润服务」内部链路,无权限校验维度。
|
|
|
|
|
+ // 不产生断言失败;仅为三流覆盖标注「不适用」。
|
|
|
|
|
+ assertTrue(true, "权限流不适用:team.profit.hook 为内部服务钩子,无用户权限维度。");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ------------------------------------------------------------------
|
|
|
|
|
+ // 反射辅助
|
|
|
|
|
+ // ------------------------------------------------------------------
|
|
|
|
|
+
|
|
|
|
|
+ private Class<?> loadClass(String name) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ return Class.forName(name, false, Thread.currentThread().getContextClassLoader());
|
|
|
|
|
+ } catch (ClassNotFoundException e) {
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private String simpleName(String fqcn) {
|
|
|
|
|
+ int idx = fqcn.lastIndexOf('.');
|
|
|
|
|
+ return idx >= 0 ? fqcn.substring(idx + 1) : fqcn;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private String readSchemaSql() {
|
|
|
|
|
+ try (InputStream is = getClass().getClassLoader().getResourceAsStream("schema.sql")) {
|
|
|
|
|
+ if (is == null) {
|
|
|
|
|
+ return "";
|
|
|
|
|
+ }
|
|
|
|
|
+ try (Scanner scanner = new Scanner(is, StandardCharsets.UTF_8.name())) {
|
|
|
|
|
+ scanner.useDelimiter("\\A");
|
|
|
|
|
+ return scanner.hasNext() ? scanner.next() : "";
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ return "";
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|