From fd5e9f0ccf0ad5b0c2ddcd9362cc68c43a0f2d76 Mon Sep 17 00:00:00 2001 From: luoweijian <1329394916@qq.com> Date: Tue, 25 Aug 2026 19:58:33 +0800 Subject: [PATCH] commit --- AGENTS.md | 21 ++ .../crm/opportunity/intake/IntakeSource.java | 45 +++ .../opportunity/intake/OpportunityIntake.java | 27 ++ .../intake/OpportunityIntakeException.java | 23 ++ .../intake/OpportunityIntakeSpec.java | 49 +++ .../intake/impl/OpportunityIntakeImpl.java | 180 +++++++++++ .../impl/OpportunityIntakeImplTest.java | 287 ++++++++++++++++++ 7 files changed, 632 insertions(+) create mode 100644 crm-opportunity/src/main/java/com/crm/opportunity/intake/IntakeSource.java create mode 100644 crm-opportunity/src/main/java/com/crm/opportunity/intake/OpportunityIntake.java create mode 100644 crm-opportunity/src/main/java/com/crm/opportunity/intake/OpportunityIntakeException.java create mode 100644 crm-opportunity/src/main/java/com/crm/opportunity/intake/OpportunityIntakeSpec.java create mode 100644 crm-opportunity/src/main/java/com/crm/opportunity/intake/impl/OpportunityIntakeImpl.java create mode 100644 crm-opportunity/src/test/java/com/crm/opportunity/intake/impl/OpportunityIntakeImplTest.java diff --git a/AGENTS.md b/AGENTS.md index e888d10..af19347 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,3 +31,24 @@ All `.java` source files **must be UTF-8 without BOM** (no `EF BB BF` byte prefi } ``` - Run a full BOM scan across all `*.java` files after bulk edits (Write/SearchReplace) and before `mvn compile`. + +## Tooling / harness + +### Stream dropouts (`Error: Stream ended without finish_reason`) + +The LLM's streamed (SSE) response was cut off before the terminal `finish_reason` event. It is **not** a bug in your code or in a tool — it is the upstream/relay dropping the connection mid-generation. + +This repo runs through a relayed provider (`PI_PROVIDER=new-provider`, `PI_MODEL=claude-opus-4-8`, forwarded via `127.0.0.1`), which makes dropouts more likely on large single turns. + +**Most common triggers, worst first:** + +- **Oversized single turn** — reading several long files at once (e.g. both `crm-lead/CONTEXT.md` + `crm-opportunity/CONTEXT.md`) then immediately doing a large write. The turn right after a big context dump drops most often. +- **One huge output** — emitting a large file (a full HTML report, hundreds of lines) in a single `write`. +- **Network / relay idle timeout** — the local forwarder or an nginx/VPN layer closing an idle SSE long-connection. + +**How to avoid it:** + +- Read large files with `offset`/`limit` in chunks; do not inhale whole long docs in one call. +- Write large files in small steps: `write` a skeleton, then append with successive `edit` calls, instead of one giant `write`. +- `/compact` or start a fresh session when the conversation history has grown large. +- Deterministic recovery is usually just **retry** — if the same action succeeds on a retry, it was relay jitter, not a real failure. diff --git a/crm-opportunity/src/main/java/com/crm/opportunity/intake/IntakeSource.java b/crm-opportunity/src/main/java/com/crm/opportunity/intake/IntakeSource.java new file mode 100644 index 0000000..1f9b16f --- /dev/null +++ b/crm-opportunity/src/main/java/com/crm/opportunity/intake/IntakeSource.java @@ -0,0 +1,45 @@ +package com.crm.opportunity.intake; + +/** + * 商机进入方式(建档来源)。 + * + *
两条建档入口的语义差异收敛于此枚举:线索侧转商机({@link #LEAD_CONVERT})与商机侧直接 + * 新建({@link #DIRECT})。承载两处按来源分叉的细节——初始操作日志的 {@code op_source} 与 + * {@code op_desc},以及线索转入恒定的商机来源字典 code({@code defaultOppSource})。
+ * + *{@code defaultOppSource}:{@code LEAD_CONVERT} 恒为 {@code opp_source_01}(线索转入); + * {@code DIRECT} 为 null——直接创建的商机来源由用户在弹窗选择,经 spec 显式携带。
+ */ +public enum IntakeSource { + + /** 线索侧转商机(crm-lead 出站端口触发):SYSTEM 记「由线索转入创建」,来源恒 opp_source_01。 */ + LEAD_CONVERT("opp_source_01", "SYSTEM", "由线索转入创建"), + + /** 商机侧直接新建:USER 记「直接创建商机」,来源由用户选择(spec 携带)。 */ + DIRECT(null, "USER", "直接创建商机"); + + private final String defaultOppSource; + private final String oplogOpSource; + private final String oplogOpDesc; + + IntakeSource(String defaultOppSource, String oplogOpSource, String oplogOpDesc) { + this.defaultOppSource = defaultOppSource; + this.oplogOpSource = oplogOpSource; + this.oplogOpDesc = oplogOpDesc; + } + + /** 线索转入恒定的商机来源字典 code;DIRECT 为 null(由 spec 显式携带用户所选来源)。 */ + public String defaultOppSource() { + return defaultOppSource; + } + + /** 初始操作日志 op_source(LEAD_CONVERT=SYSTEM,DIRECT=USER)。 */ + public String oplogOpSource() { + return oplogOpSource; + } + + /** 初始操作日志 op_desc(LEAD_CONVERT=由线索转入创建,DIRECT=直接创建商机)。 */ + public String oplogOpDesc() { + return oplogOpDesc; + } +} diff --git a/crm-opportunity/src/main/java/com/crm/opportunity/intake/OpportunityIntake.java b/crm-opportunity/src/main/java/com/crm/opportunity/intake/OpportunityIntake.java new file mode 100644 index 0000000..2ec90d7 --- /dev/null +++ b/crm-opportunity/src/main/java/com/crm/opportunity/intake/OpportunityIntake.java @@ -0,0 +1,27 @@ +package com.crm.opportunity.intake; + +/** + * 商机建档深模块(票 02/11/12/13/16:新建商机的唯一核心机制)。 + * + *两条建档入口——线索侧转商机 port({@code OpportunityCreationPortImpl})与商机侧直接 + * 新建({@code OpportunityCreateServiceImpl})——的公共机制收敛于此:主表落库 + 阶段落位 + * + 主要意向客户子表 + 初始操作日志 + 领取人团队成员 + source_lead_id 唯一性防护, + * 一个事务内原子完成。
+ * + *接口窄(单方法),实现厚:调用方只需把各自入参翻译成 {@link OpportunityIntakeSpec}, + * 建档的全部规则与副作用都在实现内,不再两处各写一遍。
+ */ +public interface OpportunityIntake { + + /** + * 按规格建档一个商机,返回新商机 id。 + * + *同库同事务(ADR-0020):本方法标 {@code @Transactional},线索转商机路径下并入 + * crm-lead 外层事务(REQUIRED 传播),任一副作用失败整体回滚。
+ * + * @param spec 建档规格(两条入口翻译后的统一内部契约) + * @return 新建商机 id + * @throws OpportunityIntakeException source_lead_id 已被其他商机占用(预检命中或并发唯一键冲突) + */ + Long open(OpportunityIntakeSpec spec); +} diff --git a/crm-opportunity/src/main/java/com/crm/opportunity/intake/OpportunityIntakeException.java b/crm-opportunity/src/main/java/com/crm/opportunity/intake/OpportunityIntakeException.java new file mode 100644 index 0000000..2ff84d4 --- /dev/null +++ b/crm-opportunity/src/main/java/com/crm/opportunity/intake/OpportunityIntakeException.java @@ -0,0 +1,23 @@ +package com.crm.opportunity.intake; + +/** + * 商机建档领域异常(crm-opportunity 自有)。 + * + *{@link OpportunityIntake#open} 内部机制失败时抛出,语义为建档本身的领域事实 + * ——当前唯一场景是 {@code source_lead_id} 已被其他商机占用(应用层预检命中,或并发窗口 + * DB 唯一键冲突转译)。
+ * + *本异常不跨模块边界:深模块只认自己的语言,由各 adapter 在 seam 处翻译—— + * 线索侧 port 译为 {@code com.crm.lead.port.outbound.OpportunityCreationException}, + * 商机侧直接创建译为 {@code BusinessErrorException}(HTTP 友好)。
+ */ +public class OpportunityIntakeException extends RuntimeException { + + public OpportunityIntakeException(String message) { + super(message); + } + + public OpportunityIntakeException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/crm-opportunity/src/main/java/com/crm/opportunity/intake/OpportunityIntakeSpec.java b/crm-opportunity/src/main/java/com/crm/opportunity/intake/OpportunityIntakeSpec.java new file mode 100644 index 0000000..27900cb --- /dev/null +++ b/crm-opportunity/src/main/java/com/crm/opportunity/intake/OpportunityIntakeSpec.java @@ -0,0 +1,49 @@ +package com.crm.opportunity.intake; + +/** + * 商机建档规格(两条入口翻译后的统一内部契约,票 02/11/12/13/16)。 + * + *{@code OpportunityIntake.open} 的唯一入参。crm-opportunity 内部类型,不导出: + * 线索侧转商机 port({@code OpportunityCreationPortImpl})与商机侧直接新建 + * ({@code OpportunityCreateServiceImpl})各自把入参 DTO 翻译成本 spec,深模块只认本语言。
+ * + *差异收敛:{@code source} 决定初始日志文案/op_source;{@code oppSource} 由 adapter 按 + * {@code source} 填——{@code LEAD_CONVERT} 取 {@link IntakeSource#defaultOppSource()} + * (opp_source_01),{@code DIRECT} 取用户所选来源。
+ * + * @param source 进入方式(决定 oplog 文案 + op_source) + * @param oppSource 商机来源字典 code(LEAD_CONVERT 恒 opp_source_01;DIRECT 用户所选) + * @param opportunityName 商机名称(必填) + * @param industryCode 行业字典 code + * @param partyA 甲方(自由文本,可空) + * @param provinceCode 省国标 code + * @param cityCode 市国标 code + * @param remark 备注(可空) + * @param ownerUserId 领取人 = 创建人 = 商机负责人 + * @param ownerDeptId 领取人所属部门快照(阶段模板按部门解析;可空 → 默认模板兜底) + * @param customerId 主要意向客户 id(可空;非空则建 is_primary_intended 子表 + 刷主表冗余) + * @param intendedCustomer 意向客户名快照 + * @param sourceLeadId 来源线索 id(唯一性预检 key;DIRECT 非线索来源时为 null,不参与 UNIQUE) + * @param sourceLeadName 线索名称快照(可空) + * @param sourcePhone 联系电话快照(可空) + * @param sourceProductCode 需求产品 code 快照(可空) + */ +public record OpportunityIntakeSpec( + IntakeSource source, + String oppSource, + String opportunityName, + String industryCode, + String partyA, + String provinceCode, + String cityCode, + String remark, + Long ownerUserId, + Long ownerDeptId, + Long customerId, + String intendedCustomer, + Long sourceLeadId, + String sourceLeadName, + String sourcePhone, + String sourceProductCode +) { +} diff --git a/crm-opportunity/src/main/java/com/crm/opportunity/intake/impl/OpportunityIntakeImpl.java b/crm-opportunity/src/main/java/com/crm/opportunity/intake/impl/OpportunityIntakeImpl.java new file mode 100644 index 0000000..629a49c --- /dev/null +++ b/crm-opportunity/src/main/java/com/crm/opportunity/intake/impl/OpportunityIntakeImpl.java @@ -0,0 +1,180 @@ +package com.crm.opportunity.intake.impl; + +import cn.hutool.core.util.StrUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.crm.opportunity.domain.entity.Opportunity; +import com.crm.opportunity.domain.entity.OpportunityCustomer; +import com.crm.opportunity.domain.entity.OpportunityOplog; +import com.crm.opportunity.domain.entity.OpportunityTeam; +import com.crm.opportunity.domain.enums.OplogKind; +import com.crm.opportunity.domain.enums.OplogLogType; +import com.crm.opportunity.domain.enums.OpportunityStatus; +import com.crm.opportunity.domain.enums.TeamMemberPermission; +import com.crm.opportunity.intake.OpportunityIntake; +import com.crm.opportunity.intake.OpportunityIntakeException; +import com.crm.opportunity.intake.OpportunityIntakeSpec; +import com.crm.opportunity.mapper.OpportunityCustomerMapper; +import com.crm.opportunity.mapper.OpportunityMapper; +import com.crm.opportunity.mapper.OpportunityOplogMapper; +import com.crm.opportunity.mapper.OpportunityTeamMapper; +import com.crm.rule.domain.entity.OpportunityStageNode; +import com.crm.rule.domain.entity.OpportunityStageTemplate; +import com.crm.rule.service.IOpportunityStageTemplateService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 商机建档深模块实现(Candidate #1:两条入口机制收敛一处)。 + * + *原 {@code OpportunityCreationPortImpl}(线索转商机 port)与 {@code OpportunityCreateServiceImpl} + * (商机侧直接新建)逐字复制的五步建档机制(主表 + 阶段落位 + 客户子表 + 初始日志 + 团队成员) + * 在此合并。差异点经 {@link OpportunityIntakeSpec} / {@code IntakeSource} 参数化:oplog 文案/来源、 + * oppSource 取值均由 spec 携带,实现不再分叉。
+ * + *事务(ADR-0020):{@code open} 标 {@code @Transactional},线索转商机路径并入 crm-lead + * 外层事务(REQUIRED),任一副作用失败整体回滚。source_lead_id 唯一性预检 + 并发 DB 唯一键 + * 冲突转译收在本模块,抛自有 {@link OpportunityIntakeException},由各 adapter 在 seam 翻译。
+ */ +@Service +@RequiredArgsConstructor +public class OpportunityIntakeImpl implements OpportunityIntake { + + /** 领取人项目角色:商机负责人(crm-dict project_role 分组,票 02)。 */ + private static final String PROJECT_ROLE_OWNER = "project_role_01"; + + /** 主要意向客户角色(票 13,customer_role 字典)。 */ + private static final String ROLE_INTENDED = "intended"; + + private final OpportunityMapper oppMapper; + private final OpportunityCustomerMapper customerMapper; + private final OpportunityOplogMapper oplogMapper; + private final OpportunityTeamMapper teamMapper; + private final IOpportunityStageTemplateService stageTemplateService; + + @Override + @Transactional(rollbackFor = Exception.class) + public Long open(OpportunityIntakeSpec spec) { + // 票 12:source_lead_id 唯一性预检(应用层友好报错,DB 唯一键作并发最终防线)。 + // sourceLeadId 为空(DIRECT 非线索来源,NULL 不参与 UNIQUE)→ 跳过预检。 + if (spec.sourceLeadId() != null) { + Long existing = oppMapper.selectCount(new LambdaQueryWrapper覆盖建档机制:阶段落位(命中/无模板容忍空)、初始日志两来源分支、领取人团队成员、 + * 主要意向客户子表(有/无 customerId)、初始状态推进中 + 快照、source_lead_id 唯一性 + * 预检 + 并发 DuplicateKey 转译。adapter 侧的异常翻译由各自 adapter 测试覆盖。
+ */ +@DisplayName("商机建档深模块 OpportunityIntake") +@ExtendWith(MockitoExtension.class) +class OpportunityIntakeImplTest { + + private static final Long LEAD_ID = 9001L; + private static final Long CUSTOMER_ID = 5001L; + private static final Long NEW_OPP_ID = 7001L; + private static final Long STAGE_TPL_VER_ID = 6601L; + private static final Long FIRST_NODE_ID = 6611L; + private static final Long OWNER_USER_ID = 100L; + private static final Long OWNER_DEPT_ID = 200L; + + @Mock private OpportunityMapper oppMapper; + @Mock private OpportunityCustomerMapper customerMapper; + @Mock private OpportunityOplogMapper oplogMapper; + @Mock private OpportunityTeamMapper teamMapper; + @Mock private IOpportunityStageTemplateService stageTemplateService; + + @InjectMocks + private OpportunityIntakeImpl intake; + + /** 线索转入 spec(source=LEAD_CONVERT,oppSource 已由 adapter 填 opp_source_01)。 */ + private OpportunityIntakeSpec leadConvertSpec(Long customerId) { + return new OpportunityIntakeSpec( + IntakeSource.LEAD_CONVERT, "opp_source_01", "商机A", "ind01", "甲方X", + "110000", "110100", "备注", OWNER_USER_ID, OWNER_DEPT_ID, + customerId, "意向客户名", LEAD_ID, "线索A", "13800000000", "prod01"); + } + + /** 直接创建 spec(source=DIRECT,oppSource 用户所选,无来源线索)。 */ + private OpportunityIntakeSpec directSpec(String oppSource, Long customerId) { + return new OpportunityIntakeSpec( + IntakeSource.DIRECT, oppSource, "商机B", "ind02", "甲方Y", + "310000", "310100", "备注2", OWNER_USER_ID, OWNER_DEPT_ID, + customerId, "意向客户名2", null, null, null, null); + } + + /** 桩定阶段模板命中:部门解析到发布中版本 + 首节点。 */ + private void stubStageTemplateHit() { + OpportunityStageTemplate ver = new OpportunityStageTemplate(); + ver.setId(STAGE_TPL_VER_ID); + ver.setVersionNo("V1.0"); + when(stageTemplateService.resolveBindingVersion(OWNER_DEPT_ID)).thenReturn(ver); + OpportunityStageNode first = new OpportunityStageNode(); + first.setId(FIRST_NODE_ID); + first.setSeqNo(1); + OpportunityStageNode second = new OpportunityStageNode(); + second.setId(FIRST_NODE_ID + 1); + second.setSeqNo(2); + when(stageTemplateService.listNodesOfVersion(STAGE_TPL_VER_ID)) + .thenReturn(List.of(first, second)); + } + + /** insert 时给实体回填自增 id(模拟 MyBatis-Plus useGeneratedKeys)。 */ + private void stubInsertAssignsId() { + doAnswer(inv -> { + ((Opportunity) inv.getArgument(0)).setId(NEW_OPP_ID); + return 1; + }).when(oppMapper).insert(any(Opportunity.class)); + } + + // ==================== @Test 追加区 ==================== + + @Test + @DisplayName("source_lead_id 已占用 → 抛 OpportunityIntakeException,不新建") + void open_leadAlreadyLinked_throws() { + when(oppMapper.selectCount(any())).thenReturn(1L); + assertThatThrownBy(() -> intake.open(leadConvertSpec(null))) + .isInstanceOf(OpportunityIntakeException.class); + verify(oppMapper, never()).insert(any(Opportunity.class)); + } + + @Test + @DisplayName("并发窗口:预检通过后 insert 撞 DB 唯一键 → 转译为 OpportunityIntakeException") + void open_concurrentDuplicateKey_translated() { + when(oppMapper.selectCount(any())).thenReturn(0L); + doThrow(new DuplicateKeyException("uk(source_lead_id)")) + .when(oppMapper).insert(any(Opportunity.class)); + assertThatThrownBy(() -> intake.open(leadConvertSpec(null))) + .isInstanceOf(OpportunityIntakeException.class); + } + + @Test + @DisplayName("sourceLeadId 为空(DIRECT 非线索来源)→ 不做唯一性预检,正常建档") + void open_directNoLead_skipsUniquenessPrecheck() { + stubStageTemplateHit(); + stubInsertAssignsId(); + Long id = intake.open(directSpec("opp_source_02", null)); + assertThat(id).isEqualTo(NEW_OPP_ID); + verify(oppMapper, never()).selectCount(any()); + } + + @Test + @DisplayName("新建商机初始状态=推进中(2),落来源快照 + 意向客户名快照") + void open_setsAdvancingAndSnapshots() { + when(oppMapper.selectCount(any())).thenReturn(0L); + stubStageTemplateHit(); + stubInsertAssignsId(); + ArgumentCaptor