diff --git a/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/cn/wanxiang/game/module/compliance/service/ban/UserBanServiceImpl.java b/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/cn/wanxiang/game/module/compliance/service/ban/UserBanServiceImpl.java index aa9f014e..61a7f845 100644 --- a/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/cn/wanxiang/game/module/compliance/service/ban/UserBanServiceImpl.java +++ b/game-cloud/game-module-compliance/game-module-compliance-server/src/main/java/cn/wanxiang/game/module/compliance/service/ban/UserBanServiceImpl.java @@ -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); + } } } diff --git a/game-cloud/game-module-compliance/game-module-compliance-server/src/test/java/cn/wanxiang/game/module/compliance/service/ban/UserBanServiceImplTest.java b/game-cloud/game-module-compliance/game-module-compliance-server/src/test/java/cn/wanxiang/game/module/compliance/service/ban/UserBanServiceImplTest.java index 981e41a0..040c0f64 100644 --- a/game-cloud/game-module-compliance/game-module-compliance-server/src/test/java/cn/wanxiang/game/module/compliance/service/ban/UserBanServiceImplTest.java +++ b/game-cloud/game-module-compliance/game-module-compliance-server/src/test/java/cn/wanxiang/game/module/compliance/service/ban/UserBanServiceImplTest.java @@ -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 diff --git a/game-cloud/game-module-project/game-module-project-api/src/main/java/cn/wanxiang/game/module/project/api/ProjectApi.java b/game-cloud/game-module-project/game-module-project-api/src/main/java/cn/wanxiang/game/module/project/api/ProjectApi.java index a0560643..5d5e973a 100644 --- a/game-cloud/game-module-project/game-module-project-api/src/main/java/cn/wanxiang/game/module/project/api/ProjectApi.java +++ b/game-cloud/game-module-project/game-module-project-api/src/main/java/cn/wanxiang/game/module/project/api/ProjectApi.java @@ -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 unlistIfPublished(@RequestParam("gameId") Long gameId); + } diff --git a/game-cloud/game-module-project/game-module-project-server/pom.xml b/game-cloud/game-module-project/game-module-project-server/pom.xml index df6542b6..1d7678f1 100644 --- a/game-cloud/game-module-project/game-module-project-server/pom.xml +++ b/game-cloud/game-module-project/game-module-project-server/pom.xml @@ -31,6 +31,13 @@ ${revision} + + + cn.iocoder.cloud + game-module-compliance-api + ${revision} + + cn.iocoder.cloud diff --git a/game-cloud/game-module-project/game-module-project-server/src/main/java/cn/wanxiang/game/module/project/api/ProjectApiImpl.java b/game-cloud/game-module-project/game-module-project-server/src/main/java/cn/wanxiang/game/module/project/api/ProjectApiImpl.java index 5c55576c..9d3d32be 100644 --- a/game-cloud/game-module-project/game-module-project-server/src/main/java/cn/wanxiang/game/module/project/api/ProjectApiImpl.java +++ b/game-cloud/game-module-project/game-module-project-server/src/main/java/cn/wanxiang/game/module/project/api/ProjectApiImpl.java @@ -48,4 +48,10 @@ public class ProjectApiImpl implements ProjectApi { return success(projectService.createProject(reqVO, creatorUserId)); } + @Override + public CommonResult unlistIfPublished(Long gameId) { + // R3 封禁联动下架:委托 ProjectService(仅 PUBLISHED 走 reviewProject 状态机下架;非 PUBLISHED 返回 false 不抛异常) + return success(projectService.unlistIfPublished(gameId)); + } + } diff --git a/game-cloud/game-module-project/game-module-project-server/src/main/java/cn/wanxiang/game/module/project/service/project/ProjectService.java b/game-cloud/game-module-project/game-module-project-server/src/main/java/cn/wanxiang/game/module/project/service/project/ProjectService.java index 4072ae04..0457ce23 100644 --- a/game-cloud/game-module-project/game-module-project-server/src/main/java/cn/wanxiang/game/module/project/service/project/ProjectService.java +++ b/game-cloud/game-module-project/game-module-project-server/src/main/java/cn/wanxiang/game/module/project/service/project/ProjectService.java @@ -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); + } diff --git a/game-cloud/game-module-project/game-module-project-server/src/main/java/cn/wanxiang/game/module/project/service/project/ProjectServiceImpl.java b/game-cloud/game-module-project/game-module-project-server/src/main/java/cn/wanxiang/game/module/project/service/project/ProjectServiceImpl.java index 403b1a51..c9a9c301 100644 --- a/game-cloud/game-module-project/game-module-project-server/src/main/java/cn/wanxiang/game/module/project/service/project/ProjectServiceImpl.java +++ b/game-cloud/game-module-project/game-module-project-server/src/main/java/cn/wanxiang/game/module/project/service/project/ProjectServiceImpl.java @@ -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 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 "内容合规未过"; + } + /** * 校验项目存在且归属当前用户 * diff --git a/game-cloud/game-module-project/game-module-project-server/src/test/java/cn/wanxiang/game/module/project/service/project/ProjectServiceImplTest.java b/game-cloud/game-module-project/game-module-project-server/src/test/java/cn/wanxiang/game/module/project/service/project/ProjectServiceImplTest.java index 6350ce9f..c2d235b3 100644 --- a/game-cloud/game-module-project/game-module-project-server/src/test/java/cn/wanxiang/game/module/project/service/project/ProjectServiceImplTest.java +++ b/game-cloud/game-module-project/game-module-project-server/src/test/java/cn/wanxiang/game/module/project/service/project/ProjectServiceImplTest.java @@ -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 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 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();