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:
zizi 2026-06-09 02:31:32 +00:00
parent b70e90b775
commit 6c8844f3be
8 changed files with 243 additions and 27 deletions

View File

@ -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);
}
}
}

View File

@ -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

View File

@ -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);
}

View File

@ -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>

View File

@ -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));
}
}

View File

@ -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);
}

View File

@ -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 "内容合规未过";
}
/**
* 校验项目存在且归属当前用户
*

View File

@ -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();