Browse Source
票 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
9 changed files with 359 additions and 0 deletions
@ -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; |
||||
|
} |
||||
@ -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 0→1)+ 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; |
||||
|
} |
||||
@ -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; |
||||
|
} |
||||
@ -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; |
||||
|
} |
||||
@ -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> { |
||||
|
} |
||||
@ -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> { |
||||
|
} |
||||
@ -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> { |
||||
|
} |
||||
@ -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(); |
||||
|
} |
||||
|
} |
||||
@ -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…
Reference in new issue