fix(appeal): close Phase 18 KEEP-VERIFY — 8 defects (zero-coverage appeals table + 6 fake-green tests; operator-notification FK crash; appealType CHECK; escalated dead-end; processAppeal race; soft-delete filters; hardcoded resolved; evidence contract); add AppealPhase18IntegrationTest (15); full regression 494 green

This commit is contained in:
WorkBuddy
2026-09-17 21:07:25 +08:00
parent 307c9efb36
commit 3f60611a74
13 changed files with 661 additions and 151 deletions
@@ -0,0 +1,368 @@
package com.danyang.match.platform.appeal;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.danyang.match.platform.appeal.dto.AppealItemDTO;
import com.danyang.match.platform.appeal.dto.AppealProcessDTO;
import com.danyang.match.platform.appeal.dto.AppealSubmitDTO;
import com.danyang.match.platform.appeal.entity.Appeal;
import com.danyang.match.platform.appeal.mapper.AppealMapper;
import com.danyang.match.platform.appeal.service.AppealService;
import com.danyang.match.platform.audit.entity.AuditLog;
import com.danyang.match.platform.audit.mapper.AuditLogMapper;
import com.danyang.match.platform.notification.entity.Notification;
import com.danyang.match.platform.notification.mapper.NotificationMapper;
import com.danyang.match.platform.risk.entity.RiskAction;
import com.danyang.match.platform.risk.mapper.RiskActionMapper;
import com.danyang.match.platform.shared.config.FeatureFlagConfig;
import com.danyang.match.platform.shared.constant.Constants;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.*;
/**
* Phase 18(申诉)真实 DB 集成测试。
*
* 背景:此前 `appeals` 表在 H2 测试库完全缺失(只列在 EntityMigrationParityTest 的可选白名单
* 里 = 无人测),整条申诉真实 DB 路径零覆盖;`AppealServiceTest` 8 个用例中 6 个是
* `assertTrue(true)` 假绿(覆盖的恰好是 Gate 四条不变量),另 1 个用 @Mock 断言 Mockito 默认值。
*
* 本测试在补齐 `appeals` 表后,真实验证:
* - Gate A:申诉通过**只**解除目标处置,其他仍有效的处置保持有效。
* - Gate B:申诉被拒 → 原处置保持有效。
* - 幂等:同一用户对同一 action 只能有一个 pending 申诉。
* - 仅 ban / temporary_suspend 可申诉。
* - appealType 白名单 + 与处置类型一致(生产 chk_appeal_type 必崩盲区)。
* - escalate 后仍可再决策(非死胡同);并发认领只有一个赢家(原子守卫)。
* - 软删申诉从列表中过滤。
* - Feature Flag 关闭时不可提交。
*/
@SpringBootTest
@ActiveProfiles("test")
@Transactional
class AppealPhase18IntegrationTest {
@MockBean
private StringRedisTemplate stringRedisTemplate;
@Autowired
private AppealService appealService;
@Autowired
private AppealMapper appealMapper;
@Autowired
private RiskActionMapper riskActionMapper;
@Autowired
private AuditLogMapper auditLogMapper;
@Autowired
private NotificationMapper notificationMapper;
@Autowired
private FeatureFlagConfig featureFlagConfig;
private boolean originalAppealEnabled;
@BeforeEach
void enableAppeal() {
originalAppealEnabled = featureFlagConfig.isAppealEnabled();
featureFlagConfig.setAppealEnabled(true);
}
@AfterEach
void restoreFeatureFlag() {
featureFlagConfig.setAppealEnabled(originalAppealEnabled);
}
// ==================== helpers ====================
private RiskAction insertAction(String userId, String actionType, String status) {
RiskAction a = new RiskAction();
a.setId(UUID.randomUUID().toString());
a.setUserId(userId);
a.setActionType(actionType);
a.setReason("测试处置-" + actionType);
a.setCreatedBy("op-phase18");
a.setEffectiveAt(Instant.now());
a.setStatus(status);
a.setCreatedAt(Instant.now());
riskActionMapper.insert(a);
return a;
}
private String submit(String userId, String actionId, String appealType) {
AppealSubmitDTO dto = new AppealSubmitDTO();
dto.setAppealType(appealType);
dto.setRelatedActionId(actionId);
dto.setReason("我对该处置有异议,申请复核");
return (String) appealService.submitAppeal(userId, dto).get("appealId");
}
private void process(String appealId, String decision) {
AppealProcessDTO dto = new AppealProcessDTO();
dto.setDecision(decision);
dto.setDecisionNote("集成测试决策-" + decision);
appealService.processAppeal(appealId, dto, "op-phase18");
}
// ==================== D1:真实 DB 路径 ====================
@Test
void submitAppealPersistsRow() {
RiskAction ban = insertAction("u-18-submit", Constants.RISK_ACTION_BAN, Constants.RISK_ACTION_STATUS_ACTIVE);
String appealId = submit("u-18-submit", ban.getId(), Constants.APPEAL_TYPE_BAN);
Appeal saved = appealMapper.selectById(appealId);
assertNotNull(saved, "appeals 表应有真实写入");
assertEquals("u-18-submit", saved.getUserId());
assertEquals(Constants.APPEAL_TYPE_BAN, saved.getAppealType());
assertEquals(ban.getId(), saved.getRelatedActionId());
assertEquals(Constants.APPEAL_STATUS_PENDING, saved.getStatus());
long audits = auditLogMapper.selectCount(
new QueryWrapper<AuditLog>().eq("action", "SUBMIT_APPEAL").eq("resource_id", appealId));
assertEquals(1, audits, "提交申诉应写入 SUBMIT_APPEAL 审计");
}
@Test
void submitWithUnknownActionRejected() {
AppealSubmitDTO dto = new AppealSubmitDTO();
dto.setAppealType(Constants.APPEAL_TYPE_BAN);
dto.setRelatedActionId(UUID.randomUUID().toString());
dto.setReason("申诉原因足够长");
RuntimeException ex = assertThrows(RuntimeException.class,
() -> appealService.submitAppeal("u-18-unknown", dto));
assertTrue(ex.getMessage().contains("不存在"), ex.getMessage());
}
@Test
void onlyBanAndSuspendAreAppealable() {
RiskAction warning = insertAction("u-18-warn", "warning", Constants.RISK_ACTION_STATUS_ACTIVE);
AppealSubmitDTO dto = new AppealSubmitDTO();
dto.setAppealType(Constants.APPEAL_TYPE_BAN);
dto.setRelatedActionId(warning.getId());
dto.setReason("warning 不可申诉");
RuntimeException ex = assertThrows(RuntimeException.class,
() -> appealService.submitAppeal("u-18-warn", dto));
assertTrue(ex.getMessage().contains("不支持申诉"), ex.getMessage());
}
// ==================== 幂等 ====================
@Test
void idempotentSubmissionBlocksSecondPendingAppeal() {
RiskAction ban = insertAction("u-18-idem", Constants.RISK_ACTION_BAN, Constants.RISK_ACTION_STATUS_ACTIVE);
submit("u-18-idem", ban.getId(), Constants.APPEAL_TYPE_BAN);
AppealSubmitDTO dto = new AppealSubmitDTO();
dto.setAppealType(Constants.APPEAL_TYPE_BAN);
dto.setRelatedActionId(ban.getId());
dto.setReason("重复提交");
RuntimeException ex = assertThrows(RuntimeException.class,
() -> appealService.submitAppeal("u-18-idem", dto));
assertTrue(ex.getMessage().contains("已就此处置提交过申诉"), ex.getMessage());
List<Appeal> mine = appealMapper.findByUserId("u-18-idem");
assertEquals(1, mine.size(), "只能存在一条 pending 申诉");
}
// ==================== Gate A:通过只解除目标处置 ====================
@Test
void approveResolvesOnlyTargetActionAndKeepsOthersActive() {
String userId = "u-18-gate-a";
RiskAction ban = insertAction(userId, Constants.RISK_ACTION_BAN, Constants.RISK_ACTION_STATUS_ACTIVE);
RiskAction suspend = insertAction(userId, Constants.RISK_ACTION_TEMPORARY_SUSPEND,
Constants.RISK_ACTION_STATUS_ACTIVE);
String appealId = submit(userId, ban.getId(), Constants.APPEAL_TYPE_BAN);
process(appealId, "approve");
assertEquals(Constants.RISK_ACTION_STATUS_RESOLVED,
riskActionMapper.selectById(ban.getId()).getStatus(), "申诉通过应解除目标处置");
assertEquals(Constants.RISK_ACTION_STATUS_ACTIVE,
riskActionMapper.selectById(suspend.getId()).getStatus(),
"其他仍有效的处置不得被解除(Gate:不得绕过其他处置)");
assertEquals(Constants.APPEAL_STATUS_APPROVED, appealMapper.selectById(appealId).getStatus());
// 账号仍受限(尚有 suspend)
Map<String, Object> status = appealService.getAccountStatus(userId);
assertEquals(Boolean.TRUE, status.get("hasRestriction"));
assertEquals("suspended", status.get("accountStatus"));
}
// ==================== Gate B:拒绝后原处置保持有效 ====================
@Test
void rejectKeepsOriginalActionActive() {
String userId = "u-18-gate-b";
RiskAction ban = insertAction(userId, Constants.RISK_ACTION_BAN, Constants.RISK_ACTION_STATUS_ACTIVE);
String appealId = submit(userId, ban.getId(), Constants.APPEAL_TYPE_BAN);
process(appealId, "reject");
assertEquals(Constants.RISK_ACTION_STATUS_ACTIVE,
riskActionMapper.selectById(ban.getId()).getStatus(), "驳回后原处置保持有效(Gate)");
assertEquals(Constants.APPEAL_STATUS_REJECTED, appealMapper.selectById(appealId).getStatus());
assertEquals(Boolean.TRUE, appealService.getAccountStatus(userId).get("hasRestriction"));
}
// ==================== 原子守卫 + escalate 非死胡同 ====================
@Test
void processingIsAtomicOnlyOneWinner() {
String userId = "u-18-atomic";
RiskAction ban = insertAction(userId, Constants.RISK_ACTION_BAN, Constants.RISK_ACTION_STATUS_ACTIVE);
String appealId = submit(userId, ban.getId(), Constants.APPEAL_TYPE_BAN);
assertEquals(1, appealMapper.markReviewingIfProcessable(appealId), "第一个认领者应成功");
assertEquals(0, appealMapper.markReviewingIfProcessable(appealId),
"已被认领(reviewing)后不得再次成功,否则并发双处理无法阻止");
}
@Test
void secondProcessAfterDecisionIsRejected() {
String userId = "u-18-double";
RiskAction ban = insertAction(userId, Constants.RISK_ACTION_BAN, Constants.RISK_ACTION_STATUS_ACTIVE);
String appealId = submit(userId, ban.getId(), Constants.APPEAL_TYPE_BAN);
process(appealId, "approve");
RuntimeException ex = assertThrows(RuntimeException.class, () -> process(appealId, "reject"));
assertTrue(ex.getMessage().contains("已处理完毕"), ex.getMessage());
// 原决策不被覆盖
assertEquals(Constants.APPEAL_STATUS_APPROVED, appealMapper.selectById(appealId).getStatus());
}
@Test
void escalatedAppealRemainsProcessable() {
String userId = "u-18-esc";
RiskAction ban = insertAction(userId, Constants.RISK_ACTION_BAN, Constants.RISK_ACTION_STATUS_ACTIVE);
String appealId = submit(userId, ban.getId(), Constants.APPEAL_TYPE_BAN);
process(appealId, "escalate");
assertEquals(Constants.APPEAL_STATUS_ESCALATED, appealMapper.selectById(appealId).getStatus());
// 升级后必须出现在后台待办队列,且仍能被决策(此前是死胡同)
List<AppealItemDTO> queue = appealService.getPendingAppeals();
assertTrue(queue.stream().anyMatch(i -> appealId.equals(i.getId())),
"escalated 申诉应出现在后台待办队列");
process(appealId, "approve");
assertEquals(Constants.APPEAL_STATUS_APPROVED, appealMapper.selectById(appealId).getStatus());
assertEquals(Constants.RISK_ACTION_STATUS_RESOLVED,
riskActionMapper.selectById(ban.getId()).getStatus());
// 升级不得向 operator 发送用户通知(operators 与 users 不同表,会撞 notifications.user_id FK)
long opNotifications = notificationMapper.selectCount(
new QueryWrapper<Notification>().eq("user_id", "op-phase18"));
assertEquals(0, opNotifications, "不得把 operator 当作通知接收用户(否则 PG 上 FK 违约)");
}
// ==================== appealType 白名单(生产 CHECK 必崩盲区) ====================
@Test
void invalidAppealTypeRejected() {
RiskAction ban = insertAction("u-18-badtype", Constants.RISK_ACTION_BAN, Constants.RISK_ACTION_STATUS_ACTIVE);
AppealSubmitDTO dto = new AppealSubmitDTO();
dto.setAppealType("not_a_type");
dto.setRelatedActionId(ban.getId());
dto.setReason("非法申诉类型");
RuntimeException ex = assertThrows(RuntimeException.class,
() -> appealService.submitAppeal("u-18-badtype", dto));
assertTrue(ex.getMessage().contains("无效的申诉类型"), ex.getMessage());
}
@Test
void mismatchedAppealTypeRejected() {
RiskAction ban = insertAction("u-18-mismatch", Constants.RISK_ACTION_BAN, Constants.RISK_ACTION_STATUS_ACTIVE);
AppealSubmitDTO dto = new AppealSubmitDTO();
dto.setAppealType(Constants.APPEAL_TYPE_SUSPEND); // ban 处置却提交暂停申诉
dto.setRelatedActionId(ban.getId());
dto.setReason("类型不匹配");
RuntimeException ex = assertThrows(RuntimeException.class,
() -> appealService.submitAppeal("u-18-mismatch", dto));
assertTrue(ex.getMessage().contains("不匹配"), ex.getMessage());
}
@Test
void missingAppealTypeDerivedFromActionType() {
RiskAction suspend = insertAction("u-18-derive", Constants.RISK_ACTION_TEMPORARY_SUSPEND,
Constants.RISK_ACTION_STATUS_ACTIVE);
AppealSubmitDTO dto = new AppealSubmitDTO();
dto.setRelatedActionId(suspend.getId());
dto.setReason("缺省类型按处置推导");
String appealId = (String) appealService.submitAppeal("u-18-derive", dto).get("appealId");
assertEquals(Constants.APPEAL_TYPE_SUSPEND, appealMapper.selectById(appealId).getAppealType());
}
// ==================== 证据契约(小程序 evidenceFileIds) ====================
@Test
void evidenceFileIdsMappedIntoEvidence() {
RiskAction ban = insertAction("u-18-ev", Constants.RISK_ACTION_BAN, Constants.RISK_ACTION_STATUS_ACTIVE);
AppealSubmitDTO dto = new AppealSubmitDTO();
dto.setAppealType(Constants.APPEAL_TYPE_BAN);
dto.setRelatedActionId(ban.getId());
dto.setReason("证据字段契约测试");
dto.setEvidenceFileIds(List.of("file-a", "file-b"));
String appealId = (String) appealService.submitAppeal("u-18-ev", dto).get("appealId");
String evidence = appealMapper.selectById(appealId).getEvidence();
assertNotNull(evidence, "evidenceFileIds 应被映射进 evidence(此前恒为 null)");
assertTrue(evidence.contains("file-a") && evidence.contains("file-b"), evidence);
}
// ==================== 软删过滤 ====================
@Test
void softDeletedAppealsFiltered() {
Appeal live = new Appeal();
live.setId(UUID.randomUUID().toString());
live.setUserId("u-18-soft");
live.setAppealType(Constants.APPEAL_TYPE_BAN);
live.setReason("存活申诉");
live.setStatus(Constants.APPEAL_STATUS_PENDING);
live.setCreatedAt(Instant.now());
appealMapper.insert(live);
Appeal deleted = new Appeal();
deleted.setId(UUID.randomUUID().toString());
deleted.setUserId("u-18-soft");
deleted.setAppealType(Constants.APPEAL_TYPE_BAN);
deleted.setReason("软删申诉");
deleted.setStatus(Constants.APPEAL_STATUS_PENDING);
deleted.setCreatedAt(Instant.now());
deleted.setDeletedAt(Instant.now());
appealMapper.insert(deleted);
List<Appeal> mine = appealMapper.findByUserId("u-18-soft");
assertEquals(1, mine.size(), "软删申诉不应出现在我的申诉列表");
assertTrue(appealService.getPendingAppeals().stream().noneMatch(i -> deleted.getId().equals(i.getId())),
"软删申诉不应出现在后台待办队列");
}
// ==================== Feature Flag ====================
@Test
void featureFlagDisabledBlocksSubmission() {
featureFlagConfig.setAppealEnabled(false);
RiskAction ban = insertAction("u-18-flag", Constants.RISK_ACTION_BAN, Constants.RISK_ACTION_STATUS_ACTIVE);
AppealSubmitDTO dto = new AppealSubmitDTO();
dto.setAppealType(Constants.APPEAL_TYPE_BAN);
dto.setRelatedActionId(ban.getId());
dto.setReason("flag 关闭时不可申诉");
RuntimeException ex = assertThrows(RuntimeException.class,
() -> appealService.submitAppeal("u-18-flag", dto));
assertTrue(ex.getMessage().contains("暂未开放"), ex.getMessage());
}
}
@@ -1,98 +0,0 @@
package com.danyang.match.platform.appeal;
import com.danyang.match.platform.shared.config.FeatureFlagConfig;
import com.danyang.match.platform.shared.constant.Constants;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
/**
* Phase 18: 申诉核心不变量测试
*/
@ExtendWith(MockitoExtension.class)
class AppealServiceTest {
@Mock
private com.danyang.match.platform.appeal.mapper.AppealMapper appealMapper;
@Mock
private com.danyang.match.platform.risk.mapper.RiskActionMapper riskActionMapper;
@Mock
private com.danyang.match.platform.profile.mapper.UserProfileMapper userProfileMapper;
@Mock
private com.danyang.match.platform.audit.mapper.AuditLogMapper auditLogMapper;
@Mock
private com.danyang.match.platform.notification.service.NotificationService notificationService;
@Mock
private FeatureFlagConfig featureFlagConfig;
@InjectMocks
private com.danyang.match.platform.appeal.service.AppealService appealService;
// ==================== 常量测试 ====================
@Test
void testAppealStatusConstants() {
assertEquals("pending", Constants.APPEAL_STATUS_PENDING);
assertEquals("reviewing", Constants.APPEAL_STATUS_REVIEWING);
assertEquals("approved", Constants.APPEAL_STATUS_APPROVED);
assertEquals("rejected", Constants.APPEAL_STATUS_REJECTED);
assertEquals("escalated", Constants.APPEAL_STATUS_ESCALATED);
}
@Test
void testRiskActionStatusConstants() {
assertEquals("active", Constants.RISK_ACTION_STATUS_ACTIVE);
assertEquals("resolved", Constants.RISK_ACTION_STATUS_RESOLVED);
assertEquals("expired", Constants.RISK_ACTION_STATUS_EXPIRED);
}
@Test
void testFeatureFlagDefaultDisabled() {
// 核心不变量:FeatureFlag 默认关闭
assertFalse(featureFlagConfig.isAppealEnabled());
}
// ==================== 核心不变量测试 ====================
@Test
void testAppealOnlyForBanAndSuspend() {
// 核心不变量:仅 ban / temporary_suspend 可申诉
// warning 和 feature_limit 不可申诉
assertTrue(true);
}
@Test
void testIdempotentAppealSubmission() {
// 核心不变量:同一用户对同一 action 只能有一个 pending 申诉
assertTrue(true);
}
@Test
void testAppealApprovedRemovesAction() {
// 核心不变量:申诉通过 → 解除对应风险处置
assertTrue(true);
}
@Test
void testAppealRejectedMaintainsAction() {
// 核心不变量:申诉被拒 → 维持原处置
assertTrue(true);
}
@Test
void testEscalatedAppealNotifiesRiskOfficer() {
// 核心不变量:升级人工 → 通知风控员
assertTrue(true);
}
@Test
void testFeatureFlagControlsAppealAvailability() {
// 核心不变量:FeatureFlag 关闭时,申诉功能不可用
assertTrue(true);
}
}
@@ -53,7 +53,6 @@ class EntityMigrationParityTest {
*/
private static final Set<String> TEST_SCHEMA_OPTIONAL_TABLES = Set.of(
"activity_interests",
"appeals",
"consent_records",
"content_categories",
"interest_dictionary",
@@ -119,7 +118,11 @@ class EntityMigrationParityTest {
// 且 ReportServiceTest 只能写 assertTrue(true) 假绿。已补入 schema-h2.sql。
"reports",
"risk_events",
"risk_actions"
"risk_actions",
// Phase 18:申诉表此前仅在可选白名单(= 无人测),
// 导致 submitAppeal / processAppeal / approve / reject / escalate 整条真实 DB 路径零覆盖,
// 且 AppealServiceTest 有 6 处 assertTrue(true) 假绿。已补入 schema-h2.sql。
"appeals"
);
/** BaseEntity 声明的公共列(对照 shared/base/BaseEntity.java)。 */
private static final Set<String> BASE_ENTITY_COLUMNS =
+23
View File
@@ -411,3 +411,26 @@ CREATE TABLE risk_actions (
CONSTRAINT chk_risk_action_status CHECK (status IN ('active', 'resolved', 'expired'))
);
-- Phase 18: appeals 表(申诉)—— 此前完全缺失于测试库(只列在 EntityMigrationParityTest
-- 的可选白名单里 = 无人测),导致申诉整条真实 DB 路径零覆盖,AppealServiceTest 只能写
-- assertTrue(true) 假绿。对齐 V013__add_appeals_table.sql:去外键(H2 无 operators 表),
-- 保留 chk_appeal_status / chk_appeal_type 两个白名单(「只在 PG 上炸」的必崩盲区)。
CREATE TABLE appeals (
id VARCHAR(36) PRIMARY KEY,
user_id VARCHAR(36) NOT NULL,
appeal_type VARCHAR(64) NOT NULL,
related_action_id VARCHAR(36),
reason CLOB NOT NULL,
evidence CLOB,
contact_method VARCHAR(256),
status VARCHAR(32) NOT NULL DEFAULT 'pending',
decision_note CLOB,
reviewed_by VARCHAR(36),
reviewed_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMP WITH TIME ZONE,
CONSTRAINT chk_appeal_status CHECK (status IN ('pending', 'reviewing', 'approved', 'rejected', 'escalated')),
CONSTRAINT chk_appeal_type CHECK (appeal_type IN ('ban_appeal', 'suspend_appeal'))
);