feat(wave3-phaseC接入): project.publish 注入 ComplianceGateApi + 封禁下架联动 seam
主 agent 串行收口黄金模块(project 15 测 + compliance 8 测全绿): - R1 接入:project-server 依赖 compliance-api;submitPublish 注入 ComplianceGateApi.evaluate, 映射 key=compliance 的 GateResultVO(block→拦/review→放行进审核/pass→放行);admitted=gates 全 pass - R3 接入:ProjectApi 加 unlistIfPublished seam(仅 PUBLISHED 以系统审核人调既有 reviewProject 下架, 复用状态机不重写=D2);compliance UserBanServiceImpl 封禁游戏时联动调用(失败不回滚封禁主操作) - D2 锚点:reviewProject/resolveReviewTargetStatus 字节级未改写,三测断言未动 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
b70e90b775
commit
6c8844f3be
@ -21,11 +21,10 @@ import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionU
|
||||
*
|
||||
* 核心:封禁写 game_user_ban 台账=主操作恒执行(先查重再插入);解封 status 1→0。
|
||||
*
|
||||
* 【R3 下架联动 / 本波边界 = openIssue】
|
||||
* 【R3 下架联动 / Phase C 已收口】
|
||||
* 设计目标:封禁游戏且 game.status==PUBLISHED 时才调 project 权威状态机下架(reviewProject decision=3);非 PUBLISHED 只记台账不调状态机、不抛异常。
|
||||
* 现状:ProjectApi(Phase A)仅有 getCreatorUserId/getCurrentVersionId/createProject,【无 reviewProject seam、无 game 状态查询 seam】。
|
||||
* 决策(按执行 spec §4.5 + 任务硬约束):不裸调不存在的方法、不自改 project-api(那是主 agent Phase A 单点)。
|
||||
* 故本波只写 ban 台账,下架联动收敛在 {@link #maybeUnlistPublishedGame} 一处占位(TODO 明注),留 Phase C 主 agent 收口接 project 状态机。
|
||||
* 现状:ProjectApi 已补 unlistIfPublished seam(状态查询 + 下架决策均收敛在 project 权威侧,复用 reviewProject 不重写状态机=D2)。
|
||||
* 接入:封禁游戏目标时 {@link #maybeUnlistPublishedGame} 调 projectApi.unlistIfPublished(gameId);非游戏封禁或返回 false 均不影响封禁主操作。
|
||||
* R5 降权出流屏蔽:P1 本波不生效(不注入 feed、不建 FeedDownweightApi 孤儿 seam)。
|
||||
*
|
||||
* @author 造梦AI
|
||||
@ -38,8 +37,8 @@ public class UserBanServiceImpl implements UserBanService {
|
||||
private UserBanMapper userBanMapper;
|
||||
|
||||
/**
|
||||
* 依赖 project 的 -api(R3 下架联动收口点预留)。本波仅持有引用、不调用未就绪 seam;
|
||||
* Phase C 主 agent 在 project-api 补 reviewProject/状态查询后,于 {@link #maybeUnlistPublishedGame} 接入。
|
||||
* 依赖 project 的 -api(R3 下架联动收口点)。封禁游戏目标时调 {@link ProjectApi#unlistIfPublished}
|
||||
* 走 project 权威状态机下架(仅 PUBLISHED 生效),状态判断与下架决策均在 project 侧。
|
||||
*/
|
||||
@Resource
|
||||
private ProjectApi projectApi;
|
||||
@ -91,24 +90,30 @@ public class UserBanServiceImpl implements UserBanService {
|
||||
return userBanMapper.selectPage(reqVO);
|
||||
}
|
||||
|
||||
// ============================== R3 下架联动收口点(本波 TODO,留 Phase C) ==============================
|
||||
// ============================== R3 下架联动收口点(Phase C 已接入) ==============================
|
||||
|
||||
/**
|
||||
* 封禁游戏的下架联动(R3 状态分支收口点;本波为 TODO 占位)
|
||||
* 封禁游戏的下架联动(R3 状态分支收口点;Phase C 接 project 权威状态机)
|
||||
*
|
||||
* 目标语义:仅当 game.status==PUBLISHED 时才调 project 权威状态机 reviewProject(decision=3) 走下架;非 PUBLISHED 只记封禁、不调状态机、不抛异常。
|
||||
* 本波现状:ProjectApi 无 reviewProject seam、无 game 状态查询 seam(Phase A 只加了 getCurrentVersionId/createProject)。
|
||||
* 按硬约束「不裸调不存在的方法、不自改 project-api」,本波不实现真实下架,仅记日志占位。
|
||||
* Phase C 主 agent 收口:在 project-api 补状态查询 + reviewProject seam 后,在此处接:
|
||||
* Integer status = projectApi.getGameStatus(gameId).getCheckedData();
|
||||
* if (ProjectStatusEnum.PUBLISHED.getStatus().equals(status)) projectApi.reviewProject(gameId, REVIEW_UNLIST=3);
|
||||
* 语义:仅当 game.status==PUBLISHED 时才走 project 权威状态机 reviewProject(decision=3) 下架;非 PUBLISHED 只记封禁、不调状态机、不抛异常。
|
||||
* 状态查询与下架决策均收敛在 project 的 {@link ProjectApi#unlistIfPublished}(复用 reviewProject 不重写状态机=D2),compliance 侧只触发并记日志。
|
||||
* 容错:下架联动是封禁的「附带副作用」,其失败(如 project 不可用)不得回滚封禁主操作,故全程 try/catch 兜底、只记 warn 不抛出。
|
||||
*
|
||||
* @param gameId 被封禁游戏 ID
|
||||
*/
|
||||
private void maybeUnlistPublishedGame(Long gameId) {
|
||||
// TODO(Phase C 主 agent 收口):接 project reviewProject 状态机下架——仅 PUBLISHED 调,非 PUBLISHED 只记台账不抛异常。
|
||||
// 本波因 ProjectApi 无 reviewProject/状态查询 seam,下架联动不生效(openIssue),ban 台账已写入。
|
||||
log.warn("[maybeUnlistPublishedGame][gameId={} 封禁台账已写入;下架联动 TODO 留 Phase C 收口(project-api 暂无 reviewProject seam)]", gameId);
|
||||
try {
|
||||
// 调 project 权威状态机:仅 PUBLISHED 触发下架返回 true;非 PUBLISHED/不存在返回 false(不抛异常)
|
||||
Boolean unlisted = projectApi.unlistIfPublished(gameId).getCheckedData();
|
||||
if (Boolean.TRUE.equals(unlisted)) {
|
||||
log.info("[maybeUnlistPublishedGame][gameId={} 已发布 → 封禁联动下架成功]", gameId);
|
||||
} else {
|
||||
log.info("[maybeUnlistPublishedGame][gameId={} 非已发布态 → 仅记封禁台账,未触发下架]", gameId);
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
// 联动失败不影响封禁主操作:台账已写入,仅告警留待人工/补偿处理
|
||||
log.warn("[maybeUnlistPublishedGame][gameId={} 封禁台账已写入;下架联动调用失败(不影响封禁主操作)]", gameId, ex);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -7,6 +7,7 @@ import cn.wanxiang.game.module.compliance.enums.BanStatusEnum;
|
||||
import cn.wanxiang.game.module.compliance.enums.BanTargetTypeEnum;
|
||||
import cn.wanxiang.game.module.project.api.ProjectApi;
|
||||
import cn.iocoder.yudao.framework.common.exception.ServiceException;
|
||||
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
|
||||
import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
@ -22,13 +23,13 @@ import static org.mockito.Mockito.*;
|
||||
* {@link UserBanServiceImpl} 单元测试(纯 Mockito,不依赖 DB;仿 AdSlotServiceImplTest 范式)
|
||||
*
|
||||
* 覆盖 R3 封禁状态分支核心:
|
||||
* - 封禁游戏:写台账=主操作恒执行(断言 insert 被调用);下架联动本波 TODO(断言不裸调 project-api,seam 未就绪);
|
||||
* - 封禁用户:写台账(用户目标无下架联动);
|
||||
* - 封禁游戏:写台账=主操作恒执行(断言 insert 被调用);下架联动 Phase C 已接入(断言调 projectApi.unlistIfPublished);
|
||||
* - 封禁用户:写台账(用户目标无下架联动,断言不调 project-api 守边界);
|
||||
* - 查重:同目标已有生效封禁 → 报 COMPLIANCE_BAN_ALREADY_EXISTS,不再插入;
|
||||
* - 类型非法 → 报 COMPLIANCE_BAN_TARGET_INVALID。
|
||||
*
|
||||
* 【说明 R3/openIssue】ProjectApi 本波无 reviewProject/状态查询 seam,下架联动留 Phase C;
|
||||
* 故本测试断言「封禁恒写台账 + 不调用 project-api」= 当前 seam 边界下 R3 状态分支的诚实形态(不伪造未就绪的下架调用)。
|
||||
* 【R3 Phase C 已收口】ProjectApi 已补 unlistIfPublished seam(状态查询 + 下架决策收敛在 project 权威侧);
|
||||
* 故游戏封禁断言「写台账 + 调 unlistIfPublished」,用户封禁断言「写台账 + 不调 project-api」守类型边界。
|
||||
*
|
||||
* @author 造梦AI
|
||||
*/
|
||||
@ -45,7 +46,7 @@ class UserBanServiceImplTest extends BaseMockitoUnitTest {
|
||||
// ============================== createBan ==============================
|
||||
|
||||
@Test
|
||||
void testCreateBan_game_writesLedgerAndNoUnlistCallThisWave() {
|
||||
void testCreateBan_game_writesLedgerAndUnlists() {
|
||||
// 准备:游戏目标,无已生效封禁;insert 回填自增 ID
|
||||
when(userBanMapper.selectActiveByTarget(BanTargetTypeEnum.GAME.getType(), 1024L)).thenReturn(null);
|
||||
doAnswer(inv -> {
|
||||
@ -53,6 +54,8 @@ class UserBanServiceImplTest extends BaseMockitoUnitTest {
|
||||
d.setId(1L);
|
||||
return 1;
|
||||
}).when(userBanMapper).insert(any(UserBanDO.class));
|
||||
// R3 下架联动 seam:project 侧返回 true(已发布已下架)
|
||||
when(projectApi.unlistIfPublished(1024L)).thenReturn(CommonResult.success(Boolean.TRUE));
|
||||
|
||||
BanCreateReqVO reqVO = mockBan(BanTargetTypeEnum.GAME.getType(), 1024L, "违规内容");
|
||||
Long id = userBanService.createBan(reqVO, 1L);
|
||||
@ -66,8 +69,8 @@ class UserBanServiceImplTest extends BaseMockitoUnitTest {
|
||||
assertEquals(1024L, saved.getTargetId());
|
||||
assertEquals(BanStatusEnum.ACTIVE.getStatus(), saved.getStatus());
|
||||
assertEquals(1, saved.getBanType()); // 缺省封禁
|
||||
// 断言:本波下架联动 TODO——不裸调 project-api(reviewProject seam 未就绪,留 Phase C 收口)
|
||||
verifyNoInteractions(projectApi);
|
||||
// 断言:R3 Phase C 下架联动已接入——封禁游戏目标调 project 权威状态机下架 seam
|
||||
verify(projectApi).unlistIfPublished(1024L);
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@ -71,4 +71,19 @@ public interface ProjectApi {
|
||||
@RequestParam("title") String title,
|
||||
@RequestParam("templateId") String templateId);
|
||||
|
||||
/**
|
||||
* 封禁联动下架 seam(R3,compliance → project 状态机)
|
||||
*
|
||||
* 语义:仅当游戏 status==PUBLISHED 时,以系统审核人身份走 project 权威状态机下架(复用 reviewProject decision=3 UNLIST,不重写状态机=D2);
|
||||
* 非 PUBLISHED 返回 false、不动状态机、不抛异常(封禁主操作不被联动失败回滚)。
|
||||
* 由 compliance 的 UserBanServiceImpl 在封禁游戏目标时调用;status 查询与下架决策均收敛在 project 权威侧。
|
||||
*
|
||||
* @param gameId 被封禁游戏 ID(game_project.id)
|
||||
* @return 是否触发了下架(true=游戏处于 PUBLISHED 已下架;false=非 PUBLISHED 未触发)(CommonResult 包裹)
|
||||
*/
|
||||
@PostMapping(PREFIX + "/unlist-if-published")
|
||||
@Operation(summary = "封禁联动下架(仅 PUBLISHED 时走 project 状态机,R3)")
|
||||
@Parameter(name = "gameId", description = "游戏项目 ID", required = true, example = "1024")
|
||||
CommonResult<Boolean> unlistIfPublished(@RequestParam("gameId") Long gameId);
|
||||
|
||||
}
|
||||
|
||||
@ -31,6 +31,13 @@
|
||||
<version>${revision}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- 依赖 compliance 的 -api:发布前注入合规锁风门 Gate(project.publish → ComplianceGateApi.evaluate;跨模块只依赖对方 -api,禁依赖 -server,与 ad-server 依赖 project-api 同构) -->
|
||||
<dependency>
|
||||
<groupId>cn.iocoder.cloud</groupId>
|
||||
<artifactId>game-module-compliance-api</artifactId>
|
||||
<version>${revision}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- 业务组件:数据权限(创作者只见自己数据)+ 多租户(DO 继承 TenantBaseDO) -->
|
||||
<dependency>
|
||||
<groupId>cn.iocoder.cloud</groupId>
|
||||
|
||||
@ -48,4 +48,10 @@ public class ProjectApiImpl implements ProjectApi {
|
||||
return success(projectService.createProject(reqVO, creatorUserId));
|
||||
}
|
||||
|
||||
@Override
|
||||
public CommonResult<Boolean> unlistIfPublished(Long gameId) {
|
||||
// R3 封禁联动下架:委托 ProjectService(仅 PUBLISHED 走 reviewProject 状态机下架;非 PUBLISHED 返回 false 不抛异常)
|
||||
return success(projectService.unlistIfPublished(gameId));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -103,4 +103,15 @@ public interface ProjectService {
|
||||
*/
|
||||
Long getCurrentVersionId(Long gameId);
|
||||
|
||||
/**
|
||||
* 封禁联动下架(R3,供 compliance 跨模块调用)
|
||||
*
|
||||
* 仅当游戏 status==PUBLISHED 时,以系统审核人复用 {@link #reviewProject} (decision=3 下架) 走权威状态机下架(不重写状态机=D2);
|
||||
* 非 PUBLISHED 返回 false,不动状态机、不抛异常(封禁主操作不被联动失败回滚)。
|
||||
*
|
||||
* @param gameId 被封禁游戏 ID(game_project.id)
|
||||
* @return true=已触发下架;false=非 PUBLISHED 未触发(含游戏不存在)
|
||||
*/
|
||||
boolean unlistIfPublished(Long gameId);
|
||||
|
||||
}
|
||||
|
||||
@ -15,6 +15,10 @@ import cn.wanxiang.game.module.project.dal.mysql.project.ProjectMapper;
|
||||
import cn.wanxiang.game.module.project.dal.mysql.review.ReviewRecordMapper;
|
||||
import cn.wanxiang.game.module.project.enums.ProjectStatusEnum;
|
||||
import cn.wanxiang.game.module.project.enums.ReviewDecisionEnum;
|
||||
import cn.wanxiang.game.module.compliance.api.ComplianceGateApi;
|
||||
import cn.wanxiang.game.module.compliance.dto.ComplianceGateReqDTO;
|
||||
import cn.wanxiang.game.module.compliance.dto.GateVerdict;
|
||||
import cn.wanxiang.game.module.compliance.enums.ComplianceVerdictEnum;
|
||||
import cn.iocoder.yudao.framework.common.pojo.PageResult;
|
||||
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
|
||||
import jakarta.annotation.Resource;
|
||||
@ -39,12 +43,25 @@ import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionU
|
||||
@Service
|
||||
public class ProjectServiceImpl implements ProjectService {
|
||||
|
||||
/**
|
||||
* 系统审核人用户 ID(R3 封禁联动下架走 reviewProject 时的 reviewerUserId)。
|
||||
* 取 1L=超管 admin:封禁下架属系统/运营动作,无人工审核人上下文,故以系统超管身份落审核记录,便于溯源。
|
||||
*/
|
||||
private static final Long SYSTEM_REVIEWER_ID = 1L;
|
||||
|
||||
@Resource
|
||||
private ProjectMapper projectMapper;
|
||||
|
||||
@Resource
|
||||
private ReviewRecordMapper reviewRecordMapper;
|
||||
|
||||
/**
|
||||
* 合规锁风门 Gate(compliance → project.publish seam,R1)。
|
||||
* 同进程内由 compliance-server 的 ComplianceGateApiImpl(@RestController @Primary) 就地解析(仿 yudao DictDataApi);拆微服务后走真实 Feign。
|
||||
*/
|
||||
@Resource
|
||||
private ComplianceGateApi complianceGateApi;
|
||||
|
||||
@Override
|
||||
public PageResult<ProjectDO> getMyProjectPage(ProjectPageReqVO reqVO, Long userId) {
|
||||
return projectMapper.selectMyPage(reqVO, userId);
|
||||
@ -110,8 +127,31 @@ public class ProjectServiceImpl implements ProjectService {
|
||||
String.valueOf(PROJECT_PUBLISH_AGE_RATING_REQUIRED.getCode()),
|
||||
PROJECT_PUBLISH_AGE_RATING_REQUIRED.getMsg()));
|
||||
}
|
||||
// TODO Phase C 接入:经 ComplianceGateApi.evaluate(gameId, versionId) 注入 key=compliance 的 GateResultVO;
|
||||
// 此刻 compliance-api 未建,本波 Phase A 不注入(gates 暂只有 age),此处不 import、不调用任何 compliance 类。
|
||||
// 合规锁风门 Gate(R1,Phase C 接入):同进程调 ComplianceGateApi.evaluate,据 verdict 映射为 key=compliance 的 GateResultVO。
|
||||
// 语义:block → pass=false(拦截,整体 admitted=false,不改状态机、不写库);pass/review → pass=true 放行进 REVIEWING,
|
||||
// 其中 review 仅标「需人工复核」文案、仍放行(人工在审核态处理)。compliance 侧 block 不抛异常,由本侧 admitted 表达拦截。
|
||||
ComplianceGateReqDTO complianceReq = new ComplianceGateReqDTO();
|
||||
complianceReq.setGameId(id);
|
||||
complianceReq.setVersionId(reqVO.getVersionId());
|
||||
// 透传内容字段供风格原子参考(可空,MVP 桩默认 pass)
|
||||
complianceReq.setTitle(project.getTitle());
|
||||
complianceReq.setSummary(project.getSummary());
|
||||
complianceReq.setAgeRating(project.getAgeRating());
|
||||
GateVerdict verdict = complianceGateApi.evaluate(complianceReq).getCheckedData();
|
||||
if (ComplianceVerdictEnum.isBlock(verdict.getVerdict())) {
|
||||
// 拦截:message 取聚合明细的首条裁决理由,无则给默认文案
|
||||
String message = resolveComplianceMessage(verdict);
|
||||
gates.add(GateResultVO.block("compliance", "合规锁风门",
|
||||
String.valueOf(verdict.getVerdict()), message));
|
||||
} else if (ComplianceVerdictEnum.REVIEW.getVerdict().equals(verdict.getVerdict())) {
|
||||
// 需人工复核:仍放行(pass=true),仅标文案提示人工在审核态处理
|
||||
GateResultVO compliancePass = GateResultVO.pass("compliance", "合规锁风门");
|
||||
compliancePass.setMessage("需人工复核");
|
||||
gates.add(compliancePass);
|
||||
} else {
|
||||
// 通过:直接放行
|
||||
gates.add(GateResultVO.pass("compliance", "合规锁风门"));
|
||||
}
|
||||
|
||||
// ===== 准入裁决 =====
|
||||
ProjectPublishRespVO resp = new ProjectPublishRespVO();
|
||||
@ -179,8 +219,47 @@ public class ProjectServiceImpl implements ProjectService {
|
||||
return project == null ? null : project.getCurrentVersionId();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean unlistIfPublished(Long gameId) {
|
||||
// R3 封禁联动下架:状态查询 + 下架决策均收敛在 project 权威侧,复用既有 reviewProject 状态机(不重写=D2)
|
||||
ProjectDO project = projectMapper.selectById(gameId);
|
||||
// 仅 PUBLISHED 才下架;游戏不存在或非 PUBLISHED 返回 false、不动状态机、不抛异常(封禁主操作不被联动回滚)
|
||||
if (project == null || !Objects.equals(project.getStatus(), ProjectStatusEnum.PUBLISHED.getStatus())) {
|
||||
return false;
|
||||
}
|
||||
// 构造下架决策入参,以系统审核人复用 reviewProject 走权威状态机(PUBLISHED → UNLISTED + 落审核记录)
|
||||
ReviewReqVO reqVO = new ReviewReqVO();
|
||||
reqVO.setGameId(gameId);
|
||||
reqVO.setVersionId(project.getCurrentVersionId());
|
||||
reqVO.setDecision(ReviewDecisionEnum.UNLIST.getDecision());
|
||||
reqVO.setReason("封禁联动下架");
|
||||
reviewProject(reqVO, SYSTEM_REVIEWER_ID);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ============================== 私有校验/流转辅助 ==============================
|
||||
|
||||
/**
|
||||
* 解析合规拦截提示文案:取聚合明细中首条 block 原子的 reason,无则回退默认文案
|
||||
*
|
||||
* @param verdict 合规聚合裁决
|
||||
* @return 可回显给创作者的拦截原因
|
||||
*/
|
||||
private String resolveComplianceMessage(GateVerdict verdict) {
|
||||
if (verdict.getDetail() != null) {
|
||||
// 优先取首条 block 原子的 reason(最贴合拦截原因)
|
||||
String reason = verdict.getDetail().stream()
|
||||
.filter(item -> ComplianceVerdictEnum.isBlock(item.getVerdict()))
|
||||
.map(item -> item.getReason())
|
||||
.filter(StringUtils::hasText)
|
||||
.findFirst().orElse(null);
|
||||
if (StringUtils.hasText(reason)) {
|
||||
return reason;
|
||||
}
|
||||
}
|
||||
return "内容合规未过";
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验项目存在且归属当前用户
|
||||
*
|
||||
|
||||
@ -10,7 +10,11 @@ import cn.wanxiang.game.module.project.dal.dataobject.review.ReviewRecordDO;
|
||||
import cn.wanxiang.game.module.project.dal.mysql.project.ProjectMapper;
|
||||
import cn.wanxiang.game.module.project.dal.mysql.review.ReviewRecordMapper;
|
||||
import cn.wanxiang.game.module.project.enums.ProjectStatusEnum;
|
||||
import cn.wanxiang.game.module.compliance.api.ComplianceGateApi;
|
||||
import cn.wanxiang.game.module.compliance.dto.GateVerdict;
|
||||
import cn.wanxiang.game.module.compliance.enums.ComplianceVerdictEnum;
|
||||
import cn.iocoder.yudao.framework.common.exception.ServiceException;
|
||||
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
|
||||
import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
@ -39,6 +43,8 @@ class ProjectServiceImplTest extends BaseMockitoUnitTest {
|
||||
private ProjectMapper projectMapper;
|
||||
@Mock
|
||||
private ReviewRecordMapper reviewRecordMapper;
|
||||
@Mock
|
||||
private ComplianceGateApi complianceGateApi; // R1 合规锁风门(发布前注入)
|
||||
|
||||
// ============================== createProject ==============================
|
||||
|
||||
@ -114,6 +120,7 @@ class ProjectServiceImplTest extends BaseMockitoUnitTest {
|
||||
ProjectDO project = ownedProject(1L, 99L, ProjectStatusEnum.DRAFT.getStatus());
|
||||
project.setAgeRating(""); // 适龄空 → 命中门禁⑦(age gate block)
|
||||
when(projectMapper.selectById(1L)).thenReturn(project);
|
||||
stubCompliance(ComplianceVerdictEnum.PASS); // 合规放行:确保只 age 门禁拦截,隔离 compliance 变量
|
||||
|
||||
ProjectPublishRespVO r = projectService.submitPublish(1L, publishReq(), 99L);
|
||||
|
||||
@ -130,10 +137,11 @@ class ProjectServiceImplTest extends BaseMockitoUnitTest {
|
||||
ProjectDO project = ownedProject(1L, 99L, ProjectStatusEnum.DRAFT.getStatus());
|
||||
project.setAgeRating("all");
|
||||
when(projectMapper.selectById(1L)).thenReturn(project);
|
||||
stubCompliance(ComplianceVerdictEnum.PASS); // 合规放行:age + compliance 双 pass
|
||||
|
||||
ProjectPublishRespVO r = projectService.submitPublish(1L, publishReq(), 99L);
|
||||
|
||||
// 准入 + 全部门禁通过
|
||||
// 准入 + 全部门禁通过(age + compliance)
|
||||
assertTrue(r.getAdmitted());
|
||||
assertTrue(r.getGates().stream().allMatch(g -> Boolean.TRUE.equals(g.getPass())));
|
||||
// 状态机置审核中 + 落版本
|
||||
@ -143,6 +151,45 @@ class ProjectServiceImplTest extends BaseMockitoUnitTest {
|
||||
assertEquals(2048L, captor.getValue().getCurrentVersionId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSubmitPublish_complianceBlock() {
|
||||
// 合规拦截:compliance 返回 block → admitted=false、含 compliance pass=false、不改状态机不写库
|
||||
ProjectDO project = ownedProject(1L, 99L, ProjectStatusEnum.DRAFT.getStatus());
|
||||
project.setAgeRating("all"); // age 通过,仅 compliance 拦截
|
||||
when(projectMapper.selectById(1L)).thenReturn(project);
|
||||
stubCompliance(ComplianceVerdictEnum.BLOCK);
|
||||
|
||||
ProjectPublishRespVO r = projectService.submitPublish(1L, publishReq(), 99L);
|
||||
|
||||
// 不准入 + compliance 门禁项 pass=false
|
||||
assertFalse(r.getAdmitted());
|
||||
assertTrue(r.getGates().stream()
|
||||
.anyMatch(g -> "compliance".equals(g.getKey()) && Boolean.FALSE.equals(g.getPass())));
|
||||
// 拦截不改状态机:绝不 updateById(带类型消歧)
|
||||
verify(projectMapper, never()).updateById(any(ProjectDO.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSubmitPublish_complianceReviewStillAdmitted() {
|
||||
// 需人工复核:compliance 返回 review → 仍 admitted=true 进 REVIEWING(人工在审核态处理)
|
||||
ProjectDO project = ownedProject(1L, 99L, ProjectStatusEnum.DRAFT.getStatus());
|
||||
project.setAgeRating("all");
|
||||
when(projectMapper.selectById(1L)).thenReturn(project);
|
||||
stubCompliance(ComplianceVerdictEnum.REVIEW);
|
||||
|
||||
ProjectPublishRespVO r = projectService.submitPublish(1L, publishReq(), 99L);
|
||||
|
||||
// 准入(review 放行)+ compliance 门禁项 pass=true 且标「需人工复核」
|
||||
assertTrue(r.getAdmitted());
|
||||
assertTrue(r.getGates().stream()
|
||||
.anyMatch(g -> "compliance".equals(g.getKey()) && Boolean.TRUE.equals(g.getPass())
|
||||
&& "需人工复核".equals(g.getMessage())));
|
||||
// 进审核中
|
||||
ArgumentCaptor<ProjectDO> captor = ArgumentCaptor.forClass(ProjectDO.class);
|
||||
verify(projectMapper).updateById(captor.capture());
|
||||
assertEquals(ProjectStatusEnum.REVIEWING.getStatus(), captor.getValue().getStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSubmitPublish_notDraftRejected() {
|
||||
// 安全前置:非草稿态仍抛 PROJECT_STATUS_NOT_DRAFT(不进 gates)
|
||||
@ -213,8 +260,51 @@ class ProjectServiceImplTest extends BaseMockitoUnitTest {
|
||||
assertEquals(ProjectStatusEnum.UNLISTED.getStatus(), captor.getValue().getStatus());
|
||||
}
|
||||
|
||||
// ============================== unlistIfPublished 封禁联动下架(R3) ==============================
|
||||
|
||||
@Test
|
||||
void testUnlistIfPublished_publishedUnlists() {
|
||||
// 已发布 → 复用 reviewProject 状态机下架(PUBLISHED → UNLISTED),返回 true
|
||||
ProjectDO project = ownedProject(1L, 99L, ProjectStatusEnum.PUBLISHED.getStatus());
|
||||
project.setCurrentVersionId(2048L);
|
||||
when(projectMapper.selectById(1L)).thenReturn(project);
|
||||
|
||||
boolean unlisted = projectService.unlistIfPublished(1L);
|
||||
|
||||
assertTrue(unlisted);
|
||||
// 走 project 权威状态机:状态置已下架 + 落审核记录(block)
|
||||
ArgumentCaptor<ProjectDO> captor = ArgumentCaptor.forClass(ProjectDO.class);
|
||||
verify(projectMapper).updateById(captor.capture());
|
||||
assertEquals(ProjectStatusEnum.UNLISTED.getStatus(), captor.getValue().getStatus());
|
||||
verify(reviewRecordMapper).insert(any(ReviewRecordDO.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testUnlistIfPublished_notPublishedNoop() {
|
||||
// 非已发布(如审核中)→ 返回 false,不动状态机、不落审核记录、不抛异常
|
||||
ProjectDO project = ownedProject(1L, 99L, ProjectStatusEnum.REVIEWING.getStatus());
|
||||
when(projectMapper.selectById(1L)).thenReturn(project);
|
||||
|
||||
boolean unlisted = projectService.unlistIfPublished(1L);
|
||||
|
||||
assertFalse(unlisted);
|
||||
verify(projectMapper, never()).updateById(any(ProjectDO.class)); // 带类型消歧
|
||||
verify(reviewRecordMapper, never()).insert(any(ReviewRecordDO.class));
|
||||
}
|
||||
|
||||
// ============================== 测试夹具 ==============================
|
||||
|
||||
/**
|
||||
* stub 合规锁风门返回指定裁决(仅含 verdict,detail 留空走默认拦截文案)
|
||||
*
|
||||
* @param verdictEnum 期望裁决(PASS/REVIEW/BLOCK)
|
||||
*/
|
||||
private void stubCompliance(ComplianceVerdictEnum verdictEnum) {
|
||||
GateVerdict verdict = new GateVerdict();
|
||||
verdict.setVerdict(verdictEnum.getVerdict());
|
||||
when(complianceGateApi.evaluate(any())).thenReturn(CommonResult.success(verdict));
|
||||
}
|
||||
|
||||
/** 构造归属指定用户、指定状态的项目 */
|
||||
private static ProjectDO ownedProject(Long id, Long ownerId, Integer status) {
|
||||
ProjectDO project = new ProjectDO();
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user