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