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:
@@ -1,5 +1,7 @@
|
||||
package com.danyang.match.platform.appeal.dto;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 申诉提交 DTO
|
||||
*/
|
||||
@@ -8,6 +10,12 @@ public class AppealSubmitDTO {
|
||||
private String relatedActionId;
|
||||
private String reason;
|
||||
private String evidence; // JSON array of file keys
|
||||
/**
|
||||
* 小程序 appealService 使用的字段名(证据图片 fileId 列表)。
|
||||
* Phase 18:此前后端只认 {@code evidence},而小程序发送的是 {@code evidenceFileIds}
|
||||
* → 证据永远为 null(字段名不匹配的契约缺陷)。服务层会将其序列化为 JSON 存入 evidence。
|
||||
*/
|
||||
private List<String> evidenceFileIds;
|
||||
private String contactMethod;
|
||||
|
||||
public String getAppealType() { return appealType; }
|
||||
@@ -18,6 +26,8 @@ public class AppealSubmitDTO {
|
||||
public void setReason(String reason) { this.reason = reason; }
|
||||
public String getEvidence() { return evidence; }
|
||||
public void setEvidence(String evidence) { this.evidence = evidence; }
|
||||
public List<String> getEvidenceFileIds() { return evidenceFileIds; }
|
||||
public void setEvidenceFileIds(List<String> evidenceFileIds) { this.evidenceFileIds = evidenceFileIds; }
|
||||
public String getContactMethod() { return contactMethod; }
|
||||
public void setContactMethod(String contactMethod) { this.contactMethod = contactMethod; }
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import com.danyang.match.platform.appeal.entity.Appeal;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
import org.apache.ibatis.annotations.Update;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
@@ -12,13 +13,27 @@ import java.util.Optional;
|
||||
@Mapper
|
||||
public interface AppealMapper extends BaseMapper<Appeal> {
|
||||
|
||||
@Select("SELECT * FROM appeals WHERE user_id = #{userId} ORDER BY created_at DESC LIMIT 20")
|
||||
// Phase 18:补 `deleted_at IS NULL`(此前 findByUserId / findPending / findByStatusPaged
|
||||
// 三处漏过滤,软删申诉会在用户列表与后台队列里继续出现,与 countByStatus 口径不一致)。
|
||||
|
||||
@Select("SELECT * FROM appeals WHERE user_id = #{userId} AND deleted_at IS NULL " +
|
||||
"ORDER BY created_at DESC LIMIT 20")
|
||||
List<Appeal> findByUserId(String userId);
|
||||
|
||||
@Select("SELECT * FROM appeals WHERE status = 'pending' ORDER BY created_at ASC")
|
||||
@Select("SELECT * FROM appeals WHERE status = 'pending' AND deleted_at IS NULL " +
|
||||
"ORDER BY created_at ASC")
|
||||
List<Appeal> findPending();
|
||||
|
||||
@Select("SELECT * FROM appeals WHERE status = #{status} ORDER BY created_at DESC LIMIT #{limit} OFFSET #{offset}")
|
||||
/**
|
||||
* 后台待办队列:pending 与 escalated 都需要运营决策。
|
||||
* Phase 18:escalated 此前既不能再次处理、又不在任何列表中 → 申诉永久滞留(死胡同)。
|
||||
*/
|
||||
@Select("SELECT * FROM appeals WHERE status IN ('pending', 'escalated') AND deleted_at IS NULL " +
|
||||
"ORDER BY created_at ASC")
|
||||
List<Appeal> findPendingOrEscalated();
|
||||
|
||||
@Select("SELECT * FROM appeals WHERE status = #{status} AND deleted_at IS NULL " +
|
||||
"ORDER BY created_at DESC LIMIT #{limit} OFFSET #{offset}")
|
||||
List<Appeal> findByStatusPaged(@Param("status") String status,
|
||||
@Param("limit") int limit,
|
||||
@Param("offset") int offset);
|
||||
@@ -33,4 +48,14 @@ public interface AppealMapper extends BaseMapper<Appeal> {
|
||||
"AND status IN ('pending', 'reviewing') AND deleted_at IS NULL LIMIT 1")
|
||||
Optional<Appeal> findActiveByUserAndAction(@Param("userId") String userId,
|
||||
@Param("actionId") String actionId);
|
||||
|
||||
/**
|
||||
* Phase 18:原子「认领处理」——只有把申诉从可处理态置为 reviewing 的那一次调用才返回 1。
|
||||
* 可认领的源状态 = pending / escalated(escalated 允许再次决策,避免升级后成为死胡同)。
|
||||
* 注意:`reviewing` 是事务内的瞬态在途标记,**不可**作为源状态——否则并发第二个调用
|
||||
* 也能从 reviewing 再次认领,原子守卫形同虚设。用于消除 processAppeal 的「先读后写」竞态。
|
||||
*/
|
||||
@Update("UPDATE appeals SET status = 'reviewing', updated_at = NOW() " +
|
||||
"WHERE id = #{id} AND status IN ('pending', 'escalated') AND deleted_at IS NULL")
|
||||
int markReviewingIfProcessable(@Param("id") String id);
|
||||
}
|
||||
|
||||
+69
-29
@@ -1,6 +1,5 @@
|
||||
package com.danyang.match.platform.appeal.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.danyang.match.platform.audit.entity.AuditLog;
|
||||
import com.danyang.match.platform.audit.mapper.AuditLogMapper;
|
||||
import com.danyang.match.platform.appeal.dto.AppealItemDTO;
|
||||
@@ -15,6 +14,7 @@ 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 com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
@@ -31,10 +31,10 @@ import java.util.stream.Collectors;
|
||||
* 核心 Invariant:
|
||||
* - 申诉功能受 FeatureFlag 控制,默认关闭
|
||||
* - 仅 ban / temporary_suspend 类处置可申诉,warning/feature_limit 不可申诉
|
||||
* - 同一用户对同一 action 只能有一次 pending 申诉(幂等)
|
||||
* - 申诉通过 → 解除对应风险处置
|
||||
* - 同一用户对同一 action 只能有一次 pending/reviewing 申诉(幂等)
|
||||
* - 申诉通过 → **只**解除目标风险处置(其他仍有效的处置保持有效)
|
||||
* - 申诉被拒 → 维持原处置
|
||||
* - 升级人工 → 标记 escalated,通知风控员
|
||||
* - 升级人工 → 标记 escalated,进入后台待办队列等待再次决策
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@@ -48,6 +48,7 @@ public class AppealService {
|
||||
private final AuditLogMapper auditLogMapper;
|
||||
private final NotificationService notificationService;
|
||||
private final FeatureFlagConfig featureFlagConfig;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
// ==================== 用户端 ====================
|
||||
|
||||
@@ -99,6 +100,10 @@ public class AppealService {
|
||||
throw new RuntimeException("申诉功能暂未开放");
|
||||
}
|
||||
|
||||
if (dto == null || dto.getRelatedActionId() == null || dto.getRelatedActionId().isBlank()) {
|
||||
throw new RuntimeException("缺少关联的处置记录");
|
||||
}
|
||||
|
||||
// 验证关联的 risk_action 存在且仍有效
|
||||
RiskAction targetAction = riskActionMapper.selectById(dto.getRelatedActionId());
|
||||
if (targetAction == null) {
|
||||
@@ -117,6 +122,22 @@ public class AppealService {
|
||||
throw new RuntimeException("当前处置类型不支持申诉");
|
||||
}
|
||||
|
||||
// Phase 18:校验 appealType 白名单(生产 chk_appeal_type),并必须与处置类型一致。
|
||||
// 此前直接透传 dto.appealType → 非法值在 PG 上撞 CHECK 约束(500),测试库无表故零覆盖。
|
||||
String expectedType = Constants.RISK_ACTION_BAN.equals(targetAction.getActionType())
|
||||
? Constants.APPEAL_TYPE_BAN : Constants.APPEAL_TYPE_SUSPEND;
|
||||
String appealType = dto.getAppealType();
|
||||
if (appealType == null || appealType.isBlank()) {
|
||||
appealType = expectedType; // 缺省按处置类型推导
|
||||
}
|
||||
if (!Constants.APPEAL_TYPE_BAN.equals(appealType) &&
|
||||
!Constants.APPEAL_TYPE_SUSPEND.equals(appealType)) {
|
||||
throw new RuntimeException("无效的申诉类型");
|
||||
}
|
||||
if (!expectedType.equals(appealType)) {
|
||||
throw new RuntimeException("申诉类型与处置类型不匹配");
|
||||
}
|
||||
|
||||
// 防重:同一用户对同一 action 只能有一个 pending/reviewing 申诉
|
||||
Optional<Appeal> existing = appealMapper.findActiveByUserAndAction(userId, dto.getRelatedActionId());
|
||||
if (existing.isPresent()) {
|
||||
@@ -125,10 +146,11 @@ public class AppealService {
|
||||
|
||||
Appeal appeal = new Appeal();
|
||||
appeal.setUserId(userId);
|
||||
appeal.setAppealType(dto.getAppealType());
|
||||
appeal.setAppealType(appealType);
|
||||
appeal.setRelatedActionId(dto.getRelatedActionId());
|
||||
appeal.setReason(dto.getReason());
|
||||
appeal.setEvidence(dto.getEvidence());
|
||||
// Phase 18:兼容小程序字段名 evidenceFileIds(此前后端只读 evidence → 证据恒为 null)
|
||||
appeal.setEvidence(resolveEvidence(dto));
|
||||
appeal.setContactMethod(dto.getContactMethod());
|
||||
appeal.setStatus(Constants.APPEAL_STATUS_PENDING);
|
||||
appealMapper.insert(appeal);
|
||||
@@ -154,10 +176,10 @@ public class AppealService {
|
||||
// ==================== 管理端 ====================
|
||||
|
||||
/**
|
||||
* 获取待处理申诉列表
|
||||
* 获取待处理申诉列表(pending + escalated,均需运营决策)
|
||||
*/
|
||||
public List<AppealItemDTO> getPendingAppeals() {
|
||||
return appealMapper.findPending().stream()
|
||||
return appealMapper.findPendingOrEscalated().stream()
|
||||
.map(this::toDTO)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
@@ -167,7 +189,7 @@ public class AppealService {
|
||||
*/
|
||||
public AppealItemDTO getAppealDetail(String appealId) {
|
||||
Appeal appeal = appealMapper.selectById(appealId);
|
||||
if (appeal == null) {
|
||||
if (appeal == null || appeal.getDeletedAt() != null) {
|
||||
throw new RuntimeException("申诉不存在");
|
||||
}
|
||||
return toDTO(appeal);
|
||||
@@ -179,22 +201,29 @@ public class AppealService {
|
||||
@Transactional
|
||||
public Map<String, Object> processAppeal(String appealId, AppealProcessDTO dto, String operatorId) {
|
||||
Appeal appeal = appealMapper.selectById(appealId);
|
||||
if (appeal == null) {
|
||||
if (appeal == null || appeal.getDeletedAt() != null) {
|
||||
throw new RuntimeException("申诉不存在");
|
||||
}
|
||||
if (!Constants.APPEAL_STATUS_PENDING.equals(appeal.getStatus()) &&
|
||||
!Constants.APPEAL_STATUS_REVIEWING.equals(appeal.getStatus())) {
|
||||
if (dto == null || dto.getDecision() == null) {
|
||||
throw new RuntimeException("无效的申诉决策");
|
||||
}
|
||||
|
||||
// Phase 18:原子「认领」——只有把申诉从可处理态(pending/reviewing/escalated)
|
||||
// 置为 reviewing 的那一次调用返回 1。消除此前的「先读后写」竞态:两个运营并发处理
|
||||
// 同一申诉时,只有一个赢家能继续,另一个直接失败,避免 approve/reject 相互覆盖。
|
||||
int claimed = appealMapper.markReviewingIfProcessable(appealId);
|
||||
if (claimed != 1) {
|
||||
throw new RuntimeException("该申诉已处理完毕");
|
||||
}
|
||||
|
||||
appeal.setStatus(Constants.APPEAL_STATUS_REVIEWING);
|
||||
appealMapper.updateById(appeal);
|
||||
// 回读最新状态(status 已为 reviewing)
|
||||
Appeal fresh = appealMapper.selectById(appealId);
|
||||
|
||||
// 根据决策执行
|
||||
switch (dto.getDecision()) {
|
||||
case "approve" -> approveAppeal(appeal, dto.getDecisionNote(), operatorId);
|
||||
case "reject" -> rejectAppeal(appeal, dto.getDecisionNote(), operatorId);
|
||||
case "escalate" -> escalateAppeal(appeal, dto.getDecisionNote(), operatorId);
|
||||
case "approve" -> approveAppeal(fresh, dto.getDecisionNote(), operatorId);
|
||||
case "reject" -> rejectAppeal(fresh, dto.getDecisionNote(), operatorId);
|
||||
case "escalate" -> escalateAppeal(fresh, dto.getDecisionNote(), operatorId);
|
||||
default -> throw new RuntimeException("无效的申诉决策");
|
||||
}
|
||||
|
||||
@@ -238,11 +267,11 @@ public class AppealService {
|
||||
appeal.setReviewedAt(Instant.now());
|
||||
appealMapper.updateById(appeal);
|
||||
|
||||
// 解除对应风险处置
|
||||
// 解除**对应**风险处置(只解除目标处置;用户其他仍有效的处置不受影响)
|
||||
if (appeal.getRelatedActionId() != null) {
|
||||
RiskAction ra = riskActionMapper.selectById(appeal.getRelatedActionId());
|
||||
if (ra != null) {
|
||||
ra.setStatus("resolved");
|
||||
ra.setStatus(Constants.RISK_ACTION_STATUS_RESOLVED);
|
||||
ra.setUpdatedAt(Instant.now());
|
||||
riskActionMapper.updateById(ra);
|
||||
}
|
||||
@@ -251,8 +280,6 @@ public class AppealService {
|
||||
}
|
||||
|
||||
// 通知用户
|
||||
UserProfile profile = userProfileMapper.findByUserId(appeal.getUserId()).orElse(null);
|
||||
String userName = profile != null ? profile.getDisplayName() : "用户";
|
||||
notificationService.send(appeal.getUserId(), Constants.NOTIFICATION_TYPE_ACCOUNT,
|
||||
"申诉审核通过",
|
||||
"您的申诉已审核通过,相关限制已解除。",
|
||||
@@ -285,14 +312,27 @@ public class AppealService {
|
||||
appeal.setReviewedAt(Instant.now());
|
||||
appealMapper.updateById(appeal);
|
||||
|
||||
log.info("申诉升级人工处理: appealId={}", appeal.getId());
|
||||
// Phase 18:此前向 operatorId 发送用户通知 —— operators 与 users 是两张独立表,
|
||||
// notifications.user_id 有 FK 指向 users(id) → 在 PG 上必然 FK 违约,导致升级事务整体回滚(500)。
|
||||
// 升级后的申诉进入后台待办队列(findPendingOrEscalated),由风控员在此队列中继续决策。
|
||||
log.warn("申诉升级人工处理,等待风控员决策: appealId={}, escalatedBy={}", appeal.getId(), operatorId);
|
||||
}
|
||||
|
||||
// 通知风控员
|
||||
notificationService.send(operatorId, Constants.NOTIFICATION_TYPE_SYSTEM,
|
||||
"申诉升级处理",
|
||||
"用户申诉已升级人工处理,请核实后决策。",
|
||||
"appeal_escalated", appeal.getId(),
|
||||
"appeal_escalated:" + appeal.getId());
|
||||
/** Phase 18:合并 evidence 与 evidenceFileIds(小程序字段名),序列化为 JSON 数组字符串。 */
|
||||
private String resolveEvidence(AppealSubmitDTO dto) {
|
||||
if (dto.getEvidence() != null && !dto.getEvidence().isBlank()) {
|
||||
return dto.getEvidence();
|
||||
}
|
||||
List<String> fileIds = dto.getEvidenceFileIds();
|
||||
if (fileIds == null || fileIds.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return objectMapper.writeValueAsString(fileIds);
|
||||
} catch (Exception e) {
|
||||
log.warn("申诉证据序列化失败: {}", e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private AppealItemDTO toDTO(Appeal appeal) {
|
||||
@@ -318,7 +358,7 @@ public class AppealService {
|
||||
}
|
||||
|
||||
private String getAppealTypeText(String type) {
|
||||
return "ban_appeal".equals(type) ? "封禁申诉" : "暂停申诉";
|
||||
return Constants.APPEAL_TYPE_BAN.equals(type) ? "封禁申诉" : "暂停申诉";
|
||||
}
|
||||
|
||||
private String calculateAccountStatus(List<RiskAction> actions) {
|
||||
|
||||
@@ -139,6 +139,10 @@ public final class Constants {
|
||||
public static final String APPEAL_STATUS_REJECTED = "rejected";
|
||||
public static final String APPEAL_STATUS_ESCALATED = "escalated";
|
||||
|
||||
// 申诉类型(必须与生产 appeals.chk_appeal_type 白名单一致)
|
||||
public static final String APPEAL_TYPE_BAN = "ban_appeal";
|
||||
public static final String APPEAL_TYPE_SUSPEND = "suspend_appeal";
|
||||
|
||||
// 通知类型
|
||||
public static final String NOTIFICATION_TYPE_SYSTEM = "system";
|
||||
public static final String NOTIFICATION_TYPE_ACCOUNT = "account";
|
||||
|
||||
+368
@@ -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);
|
||||
}
|
||||
}
|
||||
+5
-2
@@ -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 =
|
||||
|
||||
@@ -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'))
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user