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团队成员 {@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())); + } +}