From ba75e8f79ab5082920d811496b9e97a3ca7736b0 Mon Sep 17 00:00:00 2001 From: luoweijian <1329394916@qq.com> Date: Tue, 25 Aug 2026 11:45:57 +0800 Subject: [PATCH] =?UTF-8?q?feat(opportunity):=20=E8=A7=86=E5=9B=BE?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E6=BA=90=E4=B8=89=E8=A1=A8=E7=BB=93=E6=9E=84?= =?UTF-8?q?=E8=90=BD=E5=9C=B0=EF=BC=88=E5=9B=A2=E9=98=9F=E6=88=90=E5=91=98?= =?UTF-8?q?/=E5=85=B3=E6=B3=A8/=E6=9C=80=E8=BF=91=E8=AE=BF=E9=97=AE?= =?UTF-8?q?=EF=BC=8C=E7=A5=A8=2010/14=20=E5=BA=95=E5=BA=A7=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 票 10/14 四视图查询的数据底座(PRD §7.8/§7.9/§1.3): - opportunity_team 团队成员(软删+delete_key,participated 视图 + E9 移交): project_role 纯回显标签(字典) + permission 行级权限档(TeamMemberPermission 只读/可写跟进/可推进节点/读写,与角色单独配);UNIQUE(opp,user,delete_key) - opportunity_focus 关注(轻量物理删,followed 视图):UNIQUE(opp,user), 纯收藏夹不入 oplog,对齐 lead_follow 风格 - opportunity_view_log 最近访问(轻量 upsert,recent 视图):UNIQUE(user,opp) 天然去重,last_view_time 倒序,后台 Job 裁剪 四实体+Mapper骨架+TeamMemberPermission枚举+冒烟测试(表绑定/列映射/逻辑删差异)。 crm-opportunity 111→117 全绿。 --- .../domain/entity/OpportunityFocus.java | 55 ++++++++++++ .../domain/entity/OpportunityTeam.java | 68 +++++++++++++++ .../domain/entity/OpportunityViewLog.java | 56 ++++++++++++ .../domain/enums/TeamMemberPermission.java | 35 ++++++++ .../mapper/OpportunityFocusMapper.java | 9 ++ .../mapper/OpportunityTeamMapper.java | 9 ++ .../mapper/OpportunityViewLogMapper.java | 9 ++ .../enums/TeamMemberPermissionTest.java | 33 +++++++ .../OpportunityViewSourceMapperTest.java | 85 +++++++++++++++++++ 9 files changed, 359 insertions(+) create mode 100644 crm-opportunity/src/main/java/com/crm/opportunity/domain/entity/OpportunityFocus.java create mode 100644 crm-opportunity/src/main/java/com/crm/opportunity/domain/entity/OpportunityTeam.java create mode 100644 crm-opportunity/src/main/java/com/crm/opportunity/domain/entity/OpportunityViewLog.java create mode 100644 crm-opportunity/src/main/java/com/crm/opportunity/domain/enums/TeamMemberPermission.java create mode 100644 crm-opportunity/src/main/java/com/crm/opportunity/mapper/OpportunityFocusMapper.java create mode 100644 crm-opportunity/src/main/java/com/crm/opportunity/mapper/OpportunityTeamMapper.java create mode 100644 crm-opportunity/src/main/java/com/crm/opportunity/mapper/OpportunityViewLogMapper.java create mode 100644 crm-opportunity/src/test/java/com/crm/opportunity/domain/enums/TeamMemberPermissionTest.java create mode 100644 crm-opportunity/src/test/java/com/crm/opportunity/mapper/OpportunityViewSourceMapperTest.java diff --git a/crm-opportunity/src/main/java/com/crm/opportunity/domain/entity/OpportunityFocus.java b/crm-opportunity/src/main/java/com/crm/opportunity/domain/entity/OpportunityFocus.java new file mode 100644 index 0000000..9eeec32 --- /dev/null +++ b/crm-opportunity/src/main/java/com/crm/opportunity/domain/entity/OpportunityFocus.java @@ -0,0 +1,55 @@ +package com.crm.opportunity.domain.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.Data; +import org.hibernate.annotations.Comment; + +import java.time.LocalDateTime; + +/** + * 商机关注(M:N,用户 × 商机;PRD §7.8,原型「我关注的」视图 + 关注/取关按钮) + * + *

**纯收藏夹语义,不带通知**(通知属平台通知基础设施,未建)。「我关注的」视图(票 14 `followed`)= + * 该表命中当前用户;管理/公海/最近访问视图的「关注」按钮读写此表。

+ * + *

关注/取关无需原因、**物理删行即可**——关注不是审计对象、不入 {@code opportunity_oplog}(票 09 行为 + * 类别不含关注)。故对齐 crm-lead {@code lead_follow} 轻量关联表风格:**不继承 BaseEntity/不带逻辑删**, + * 取关直接 {@code delete},无 delete_key。约束 {@code UNIQUE(opportunity_id, user_id)} 防重复关注。

+ */ +@Data +@TableName("opportunity_focus") +@Entity +@Table(name = "opportunity_focus", + uniqueConstraints = { + @UniqueConstraint(name = "uk_opp_focus", columnNames = {"opportunity_id", "user_id"}) + }, + indexes = { + @Index(name = "idx_of_user", columnList = "user_id") + }) +public class OpportunityFocus { + + @TableId(type = IdType.ASSIGN_ID) + @Id + @Column(name = "id", columnDefinition = "bigint comment '主键'") + private Long id; + + @Comment("关注的商机ID(opportunity.id)") + @Column(columnDefinition = "bigint not null comment '关注的商机ID'") + private Long opportunityId; + + @Comment("关注人用户ID(引用 crm-auth SysUser)") + @Column(columnDefinition = "bigint not null comment '关注人用户ID'") + private Long userId; + + @Comment("关注时间") + @Column(columnDefinition = "datetime not null comment '关注时间'") + private LocalDateTime focusTime; +} diff --git a/crm-opportunity/src/main/java/com/crm/opportunity/domain/entity/OpportunityTeam.java b/crm-opportunity/src/main/java/com/crm/opportunity/domain/entity/OpportunityTeam.java new file mode 100644 index 0000000..f3b4585 --- /dev/null +++ b/crm-opportunity/src/main/java/com/crm/opportunity/domain/entity/OpportunityTeam.java @@ -0,0 +1,68 @@ +package com.crm.opportunity.domain.entity; + +import com.baomidou.mybatisplus.annotation.TableName; +import com.crm.base.domain.entity.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Index; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.hibernate.annotations.Comment; + +/** + * 商机-团队成员子表(PRD §1.3 / §7 通用约定 / 原型 A3-1-1-1-8 团队成员 Tab) + * + *

负责人拉入协作的人员行级授权白名单,**绕开 DataScope 部门天花板**——生效边界 = 详情/子表读 + + * 个人工作区视图(「我参与的」= 团队成员且非负责人,票 14);管理/公海工作区严格按 DataScope 不混白名单。 + * 转商机时自动插一条:领取人 = 商机负责人(票 02);抛公海只清负责人不动团队(票 01)。

+ * + *

两个正交维度(grill 拍板 20260821):{@code project_role} **项目角色 = 纯回显标签** + * (商机负责人/方案工程师/招标专员/现场工程师…,走 crm-dict,**不带任何读写权限**) + * + {@code permission} **行级权限档**({@link com.crm.opportunity.domain.enums.TeamMemberPermission}, + * 真正带权限,与角色单独配、互不绑定)。

+ * + *

移交 E9(票 04)同步维护本表:新负责人 upsert 一条角色标签=「商机负责人」;原负责人角色标签降为 + * 普通协作、**保留权限列不动**、留在团队落入「我参与的」。移除成员 = 软删(deleted 0→1)+ delete_key + * 复用键(`UNIQUE(opportunity_id, user_id, delete_key)`,移交/移除保留历史),被移除后立即从「我参与的」 + * 视图与详情白名单消失(成员身份判定一律带 `deleted=0` 存活过滤)。

+ */ +@Data +@EqualsAndHashCode(callSuper = true) +@TableName("opportunity_team") +@Entity +@Table(name = "opportunity_team", + uniqueConstraints = { + @UniqueConstraint(name = "uk_opp_team_user", columnNames = {"opportunity_id", "user_id", "delete_key"}) + }, + indexes = { + @Index(name = "idx_ot_opportunity", columnList = "opportunity_id"), + @Index(name = "idx_ot_user", columnList = "user_id") + }) +public class OpportunityTeam extends BaseEntity { + + @Comment("所属商机ID(opportunity.id)") + @Column(columnDefinition = "bigint not null comment '所属商机ID'") + private Long opportunityId; + + @Comment("成员用户ID(引用 crm-auth SysUser)") + @Column(columnDefinition = "bigint not null comment '成员用户ID'") + private Long userId; + + @Comment("成员姓名快照(免 join,团队成员 Tab 展示)") + @Column(columnDefinition = "varchar(64) comment '成员姓名快照'") + private String userNameSnapshot; + + @Comment("项目角色字典 code(纯回显标签:商机负责人/方案工程师/招标专员/现场工程师…,crm-dict「商机项目角色」分组,不带权限)") + @Column(columnDefinition = "varchar(32) comment '项目角色字典code(纯回显标签)'") + private String projectRole; + + @Comment("行级权限档:1只读 2可写跟进 3可推进节点 4读写(TeamMemberPermission,与角色单独配)") + @Column(columnDefinition = "tinyint not null default 1 comment '行级权限档'") + private Integer permission; + + @Comment("软删除复用键:存活时固定 0,逻辑删除时写为主键 id(移交/移除保留历史)") + @Column(columnDefinition = "bigint not null default 0 comment '软删除复用键'") + private Long deleteKey; +} diff --git a/crm-opportunity/src/main/java/com/crm/opportunity/domain/entity/OpportunityViewLog.java b/crm-opportunity/src/main/java/com/crm/opportunity/domain/entity/OpportunityViewLog.java new file mode 100644 index 0000000..771028c --- /dev/null +++ b/crm-opportunity/src/main/java/com/crm/opportunity/domain/entity/OpportunityViewLog.java @@ -0,0 +1,56 @@ +package com.crm.opportunity.domain.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.Data; +import org.hibernate.annotations.Comment; + +import java.time.LocalDateTime; + +/** + * 商机最近访问记录(PRD §7.9,票 10「最近访问」/ 票 14 `recent` 视图数据源) + * + *

写入时机 = **调用详情聚合接口即 upsert**(刷新 {@code last_view_time});个人/管理/公海任意皮肤都算。 + * 「最近访问」视图 = 该表命中当前用户,按 {@code last_view_time} 倒序取上限(本期默认 50,可配置), + * 按商机 id 去重留最新——靠 {@code UNIQUE(user_id, opportunity_id)} upsert 天然去重。

+ * + *

**不入 {@code opportunity_oplog}**:访问是高频行为数据,oplog 是审计轨迹(行为类别不含浏览,票 09), + * 两者分离。故对齐关注表轻量风格:**不继承 BaseEntity/不带逻辑删**,表内历史行由后台 Job 按容量/时间裁剪 + * (超出视图范围的历史无业务用途)。

+ */ +@Data +@TableName("opportunity_view_log") +@Entity +@Table(name = "opportunity_view_log", + uniqueConstraints = { + @UniqueConstraint(name = "uk_opp_view_log", columnNames = {"user_id", "opportunity_id"}) + }, + indexes = { + @Index(name = "idx_ovl_user_time", columnList = "user_id, last_view_time") + }) +public class OpportunityViewLog { + + @TableId(type = IdType.ASSIGN_ID) + @Id + @Column(name = "id", columnDefinition = "bigint comment '主键'") + private Long id; + + @Comment("访问人用户ID(引用 crm-auth SysUser)") + @Column(columnDefinition = "bigint not null comment '访问人用户ID'") + private Long userId; + + @Comment("访问的商机ID(opportunity.id)") + @Column(columnDefinition = "bigint not null comment '访问的商机ID'") + private Long opportunityId; + + @Comment("最近访问时间(每次打开详情聚合接口 upsert 刷新,recent 视图倒序锚点)") + @Column(columnDefinition = "datetime not null comment '最近访问时间'") + private LocalDateTime lastViewTime; +} diff --git a/crm-opportunity/src/main/java/com/crm/opportunity/domain/enums/TeamMemberPermission.java b/crm-opportunity/src/main/java/com/crm/opportunity/domain/enums/TeamMemberPermission.java new file mode 100644 index 0000000..8747b43 --- /dev/null +++ b/crm-opportunity/src/main/java/com/crm/opportunity/domain/enums/TeamMemberPermission.java @@ -0,0 +1,35 @@ +package com.crm.opportunity.domain.enums; + +import com.crm.base.domain.enums.HasValueEnum; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +/** + * 团队成员行级权限(PRD §1.3 团队成员权限列,grill 拍板 20260821) + * + *

团队成员是行级授权白名单,绕开 DataScope 部门天花板。每个成员行单独配一档权限, + * **与项目角色无关**(项目角色是纯回显标签,见 {@code project_role},本枚举是真正带权限的维度)。 + * 生效边界 = 详情/子表读 + 个人工作区视图;管理/公海工作区严格按 DataScope 不混白名单。

+ * + *

四档递进语义(读写 ⊃ 可推进节点/可写跟进 ⊃ 只读)非严格包含,按动作点各自判定: + * 写操作在 Controller/Service 层按成员自身权限档 + 全局权限点双重校验(PRD §6.7)。

+ */ +@Getter +@RequiredArgsConstructor +public enum TeamMemberPermission implements HasValueEnum { + + /** 只读:仅可查看详情/子表,不可写 */ + READ_ONLY(1, "只读"), + + /** 可写跟进:只读 + 可写跟进记录 */ + WRITE_FOLLOW(2, "可写跟进"), + + /** 可推进节点:只读 + 可推进业务阶段 */ + ADVANCE_STAGE(3, "可推进节点"), + + /** 读写:完整读写(不含负责人专属流转,那些走归属/权限点) */ + READ_WRITE(4, "读写"); + + private final Integer value; + private final String label; +} diff --git a/crm-opportunity/src/main/java/com/crm/opportunity/mapper/OpportunityFocusMapper.java b/crm-opportunity/src/main/java/com/crm/opportunity/mapper/OpportunityFocusMapper.java new file mode 100644 index 0000000..13d658e --- /dev/null +++ b/crm-opportunity/src/main/java/com/crm/opportunity/mapper/OpportunityFocusMapper.java @@ -0,0 +1,9 @@ +package com.crm.opportunity.mapper; + +import com.crm.base.mapper.CrmBaseMapper; +import com.crm.opportunity.domain.entity.OpportunityFocus; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface OpportunityFocusMapper extends CrmBaseMapper { +} diff --git a/crm-opportunity/src/main/java/com/crm/opportunity/mapper/OpportunityTeamMapper.java b/crm-opportunity/src/main/java/com/crm/opportunity/mapper/OpportunityTeamMapper.java new file mode 100644 index 0000000..bc80ec2 --- /dev/null +++ b/crm-opportunity/src/main/java/com/crm/opportunity/mapper/OpportunityTeamMapper.java @@ -0,0 +1,9 @@ +package com.crm.opportunity.mapper; + +import com.crm.base.mapper.CrmBaseMapper; +import com.crm.opportunity.domain.entity.OpportunityTeam; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface OpportunityTeamMapper extends CrmBaseMapper { +} diff --git a/crm-opportunity/src/main/java/com/crm/opportunity/mapper/OpportunityViewLogMapper.java b/crm-opportunity/src/main/java/com/crm/opportunity/mapper/OpportunityViewLogMapper.java new file mode 100644 index 0000000..c1e35ed --- /dev/null +++ b/crm-opportunity/src/main/java/com/crm/opportunity/mapper/OpportunityViewLogMapper.java @@ -0,0 +1,9 @@ +package com.crm.opportunity.mapper; + +import com.crm.base.mapper.CrmBaseMapper; +import com.crm.opportunity.domain.entity.OpportunityViewLog; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface OpportunityViewLogMapper extends CrmBaseMapper { +} diff --git a/crm-opportunity/src/test/java/com/crm/opportunity/domain/enums/TeamMemberPermissionTest.java b/crm-opportunity/src/test/java/com/crm/opportunity/domain/enums/TeamMemberPermissionTest.java new file mode 100644 index 0000000..7df2dc3 --- /dev/null +++ b/crm-opportunity/src/test/java/com/crm/opportunity/domain/enums/TeamMemberPermissionTest.java @@ -0,0 +1,33 @@ +package com.crm.opportunity.domain.enums; + +import com.crm.base.utils.EnumUtils; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * 团队成员行级权限枚举值域单测(票 14 视图底座)。 + */ +@DisplayName("团队成员行级权限 TeamMemberPermission") +class TeamMemberPermissionTest { + + @Test + @DisplayName("4 档:1只读 2可写跟进 3可推进节点 4读写") + void permission_domain() { + assertThat(TeamMemberPermission.values()).hasSize(4); + assertThat(TeamMemberPermission.READ_ONLY.getValue()).isEqualTo(1); + assertThat(TeamMemberPermission.WRITE_FOLLOW.getValue()).isEqualTo(2); + assertThat(TeamMemberPermission.ADVANCE_STAGE.getValue()).isEqualTo(3); + assertThat(TeamMemberPermission.READ_WRITE.getValue()).isEqualTo(4); + assertThat(TeamMemberPermission.READ_WRITE.getLabel()).isEqualTo("读写"); + } + + @Test + @DisplayName("EnumUtils.getByValue 反查(Integer 值域)") + void getByValue_roundTrip() { + assertThat(EnumUtils.getByValue(TeamMemberPermission.class, 3)) + .isEqualTo(TeamMemberPermission.ADVANCE_STAGE); + assertThat(EnumUtils.getByValue(TeamMemberPermission.class, 99)).isNull(); + } +} diff --git a/crm-opportunity/src/test/java/com/crm/opportunity/mapper/OpportunityViewSourceMapperTest.java b/crm-opportunity/src/test/java/com/crm/opportunity/mapper/OpportunityViewSourceMapperTest.java new file mode 100644 index 0000000..edaf089 --- /dev/null +++ b/crm-opportunity/src/test/java/com/crm/opportunity/mapper/OpportunityViewSourceMapperTest.java @@ -0,0 +1,85 @@ +package com.crm.opportunity.mapper; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.metadata.TableInfo; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import com.crm.base.mapper.CrmBaseMapper; +import com.crm.opportunity.domain.entity.OpportunityFocus; +import com.crm.opportunity.domain.entity.OpportunityTeam; +import com.crm.opportunity.domain.entity.OpportunityViewLog; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * 视图数据源三表 Mapper 骨架单测(票 10/14 视图底座)。 + *

团队成员 {@code opportunity_team}(软删 + delete_key,`participated` 视图 + E9 移交)、 + * 关注 {@code opportunity_focus}(轻量物理删,`followed` 视图)、 + * 最近访问 {@code opportunity_view_log}(轻量 upsert,`recent` 视图)。 + * 验证表绑定 + 驼峰列映射 + 逻辑删差异(team 有软删无 focus/view_log)。

+ */ +@DisplayName("视图数据源三表 Mapper 骨架(票 10/14)") +class OpportunityViewSourceMapperTest { + + @BeforeAll + static void initTableInfo() { + MapperBuilderAssistant assistant = + new MapperBuilderAssistant(new MybatisConfiguration(), ""); + TableInfoHelper.initTableInfo(assistant, OpportunityTeam.class); + TableInfoHelper.initTableInfo(assistant, OpportunityFocus.class); + TableInfoHelper.initTableInfo(assistant, OpportunityViewLog.class); + } + + @Test + @DisplayName("三 Mapper 继承 CrmBaseMapper + @Mapper 注解") + void mappersWellFormed() { + assertThat(CrmBaseMapper.class).isAssignableFrom(OpportunityTeamMapper.class); + assertThat(CrmBaseMapper.class).isAssignableFrom(OpportunityFocusMapper.class); + assertThat(CrmBaseMapper.class).isAssignableFrom(OpportunityViewLogMapper.class); + assertThat(OpportunityTeamMapper.class.getAnnotation(Mapper.class)).isNotNull(); + assertThat(OpportunityFocusMapper.class.getAnnotation(Mapper.class)).isNotNull(); + assertThat(OpportunityViewLogMapper.class.getAnnotation(Mapper.class)).isNotNull(); + } + + @Test + @DisplayName("opportunity_team:绑表 + 列映射 opportunity_id/user_id/project_role/permission + 软删 delete_key") + void teamTableInfo() { + TableInfo info = TableInfoHelper.getTableInfo(OpportunityTeam.class); + assertThat(info.getTableName()).isEqualTo("opportunity_team"); + assertThat(info.getFieldList()).anyMatch(f -> + "opportunity_id".equals(f.getColumn()) && "opportunityId".equals(f.getProperty())); + assertThat(info.getFieldList()).anyMatch(f -> + "project_role".equals(f.getColumn()) && "projectRole".equals(f.getProperty())); + assertThat(info.getFieldList()).anyMatch(f -> "permission".equals(f.getColumn())); + // 软删 + delete_key 复用键(移交/移除保留历史) + assertThat(info.getLogicDeleteFieldInfo()).isNotNull(); + assertThat(info.getFieldList()).anyMatch(f -> "delete_key".equals(f.getColumn())); + } + + @Test + @DisplayName("opportunity_focus:绑表 + 列映射 opportunity_id/user_id/focus_time + 轻量无逻辑删") + void focusTableInfo() { + TableInfo info = TableInfoHelper.getTableInfo(OpportunityFocus.class); + assertThat(info.getTableName()).isEqualTo("opportunity_focus"); + assertThat(info.getFieldList()).anyMatch(f -> + "focus_time".equals(f.getColumn()) && "focusTime".equals(f.getProperty())); + // 关注纯物理删,非审计对象:无逻辑删、无 delete_key + assertThat(info.getLogicDeleteFieldInfo()).isNull(); + assertThat(info.getFieldList()).noneMatch(f -> "delete_key".equals(f.getColumn())); + } + + @Test + @DisplayName("opportunity_view_log:绑表 + 列映射 user_id/opportunity_id/last_view_time + 轻量无逻辑删") + void viewLogTableInfo() { + TableInfo info = TableInfoHelper.getTableInfo(OpportunityViewLog.class); + assertThat(info.getTableName()).isEqualTo("opportunity_view_log"); + assertThat(info.getFieldList()).anyMatch(f -> + "last_view_time".equals(f.getColumn()) && "lastViewTime".equals(f.getProperty())); + assertThat(info.getLogicDeleteFieldInfo()).isNull(); + assertThat(info.getFieldList()).noneMatch(f -> "delete_key".equals(f.getColumn())); + } +}