Browse Source

feat(opportunity): 视图数据源三表结构落地(团队成员/关注/最近访问,票 10/14 底座)

票 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 全绿。
master
luoweijian 2 weeks ago
parent
commit
ba75e8f79a
  1. 55
      crm-opportunity/src/main/java/com/crm/opportunity/domain/entity/OpportunityFocus.java
  2. 68
      crm-opportunity/src/main/java/com/crm/opportunity/domain/entity/OpportunityTeam.java
  3. 56
      crm-opportunity/src/main/java/com/crm/opportunity/domain/entity/OpportunityViewLog.java
  4. 35
      crm-opportunity/src/main/java/com/crm/opportunity/domain/enums/TeamMemberPermission.java
  5. 9
      crm-opportunity/src/main/java/com/crm/opportunity/mapper/OpportunityFocusMapper.java
  6. 9
      crm-opportunity/src/main/java/com/crm/opportunity/mapper/OpportunityTeamMapper.java
  7. 9
      crm-opportunity/src/main/java/com/crm/opportunity/mapper/OpportunityViewLogMapper.java
  8. 33
      crm-opportunity/src/test/java/com/crm/opportunity/domain/enums/TeamMemberPermissionTest.java
  9. 85
      crm-opportunity/src/test/java/com/crm/opportunity/mapper/OpportunityViewSourceMapperTest.java

55
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原型我关注的视图 + 关注/取关按钮
*
* <p>**纯收藏夹语义不带通知**通知属平台通知基础设施未建我关注的视图 14 `followed`=
* 该表命中当前用户管理/公海/最近访问视图的关注按钮读写此表</p>
*
* <p>关注/取关无需原因**物理删行即可**关注不是审计对象不入 {@code opportunity_oplog} 09 行为
* 类别不含关注故对齐 crm-lead {@code lead_follow} 轻量关联表风格**不继承 BaseEntity/不带逻辑删**
* 取关直接 {@code delete} delete_key约束 {@code UNIQUE(opportunity_id, user_id)} 防重复关注</p>
*/
@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;
}

68
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
*
* <p>负责人拉入协作的人员行级授权白名单**绕开 DataScope 部门天花板**生效边界 = 详情/子表读 +
* 个人工作区视图我参与的= 团队成员且非负责人 14管理/公海工作区严格按 DataScope 不混白名单
* 转商机时自动插一条领取人 = 商机负责人 02抛公海只清负责人不动团队 01</p>
*
* <p>两个正交维度grill 拍板 20260821{@code project_role} **项目角色 = 纯回显标签**
* 商机负责人/方案工程师/招标专员/现场工程师 crm-dict**不带任何读写权限**
* + {@code permission} **行级权限档**{@link com.crm.opportunity.domain.enums.TeamMemberPermission}
* 真正带权限与角色单独配互不绑定</p>
*
* <p>移交 E9 04同步维护本表新负责人 upsert 一条角色标签=商机负责人原负责人角色标签降为
* 普通协作**保留权限列不动**留在团队落入我参与的移除成员 = 软删deleted 01+ delete_key
* 复用键`UNIQUE(opportunity_id, user_id, delete_key)`移交/移除保留历史被移除后立即从我参与的
* 视图与详情白名单消失成员身份判定一律带 `deleted=0` 存活过滤</p>
*/
@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;
}

56
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` 视图数据源
*
* <p>写入时机 = **调用详情聚合接口即 upsert**刷新 {@code last_view_time}个人/管理/公海任意皮肤都算
* 最近访问视图 = 该表命中当前用户 {@code last_view_time} 倒序取上限本期默认 50可配置
* 按商机 id 去重留最新 {@code UNIQUE(user_id, opportunity_id)} upsert 天然去重</p>
*
* <p>**不入 {@code opportunity_oplog}**访问是高频行为数据oplog 是审计轨迹行为类别不含浏览 09
* 两者分离故对齐关注表轻量风格**不继承 BaseEntity/不带逻辑删**表内历史行由后台 Job 按容量/时间裁剪
* 超出视图范围的历史无业务用途</p>
*/
@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;
}

35
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
*
* <p>团队成员是行级授权白名单绕开 DataScope 部门天花板每个成员行单独配一档权限
* **与项目角色无关**项目角色是纯回显标签 {@code project_role}本枚举是真正带权限的维度
* 生效边界 = 详情/子表读 + 个人工作区视图管理/公海工作区严格按 DataScope 不混白名单</p>
*
* <p>四档递进语义读写 可推进节点/可写跟进 只读非严格包含按动作点各自判定
* 写操作在 Controller/Service 层按成员自身权限档 + 全局权限点双重校验PRD §6.7</p>
*/
@Getter
@RequiredArgsConstructor
public enum TeamMemberPermission implements HasValueEnum<Integer> {
/** 只读:仅可查看详情/子表,不可写 */
READ_ONLY(1, "只读"),
/** 可写跟进:只读 + 可写跟进记录 */
WRITE_FOLLOW(2, "可写跟进"),
/** 可推进节点:只读 + 可推进业务阶段 */
ADVANCE_STAGE(3, "可推进节点"),
/** 读写:完整读写(不含负责人专属流转,那些走归属/权限点) */
READ_WRITE(4, "读写");
private final Integer value;
private final String label;
}

9
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<OpportunityFocus> {
}

9
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<OpportunityTeam> {
}

9
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<OpportunityViewLog> {
}

33
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();
}
}

85
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 视图底座
* <p>团队成员 {@code opportunity_team}软删 + delete_key`participated` 视图 + E9 移交
* 关注 {@code opportunity_focus}轻量物理删`followed` 视图
* 最近访问 {@code opportunity_view_log}轻量 upsert`recent` 视图
* 验证表绑定 + 驼峰列映射 + 逻辑删差异team 有软删无 focus/view_log</p>
*/
@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()));
}
}
Loading…
Cancel
Save