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:
@@ -32,7 +32,7 @@
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="120">
|
||||
<template #default="{row}">
|
||||
<el-button v-if="row.status==='pending'" type="primary" size="small" @click="openDetail(row.id)">处理</el-button>
|
||||
<el-button v-if="row.status==='pending'||row.status==='escalated'" type="primary" size="small" @click="openDetail(row.id)">处理</el-button>
|
||||
<el-button size="small" @click="openDetail(row.id)">查看</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
@@ -59,7 +59,7 @@
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<template v-if="detail.status==='pending'||detail.status==='reviewing'">
|
||||
<template v-if="detail.status==='pending'||detail.status==='reviewing'||detail.status==='escalated'">
|
||||
<h4 style="margin:20px 0 12px;color:#E96B6B">审核决策</h4>
|
||||
<el-form label-width="100px">
|
||||
<el-form-item label="处理结果">
|
||||
|
||||
@@ -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'))
|
||||
);
|
||||
|
||||
|
||||
+19
-9
@@ -19,7 +19,10 @@
|
||||
>
|
||||
> **2026-09-17 Phase 17 复验关闭:** EPIC-P17-V11 已完成。复验发现风控/举报整条真实 DB 路径因测试库缺 `reports`/`risk_events`/`risk_actions` 三表而**零覆盖**,`ReportServiceTest` 全是 `assertTrue(true)` 假绿;且敏感访问(查看他人风险画像/举报)此前**无审计**(违反 AGENTS.md #8 默认拒绝 + 查看需审计);另修复审计缺 `ip`/`userAgent`、`findByUserId` 未过滤软删、人工标记无审计,以及 `javax`→`jakarta` 隐藏编译缺陷与 `risk_events` 漏 `updated_at` 列。共修复 **6 处真实缺陷**,补齐三表并新增 `ReportAndRiskPhase17IntegrationTest`(7 例),负向验证证明守卫有牙。全量回归 **488 tests 全绿**。
|
||||
> 证据:`docs/Phase-17-V11-Recovery-Completion-Report-2026-09-17.md`。
|
||||
> **Phase 18 需用户确认后启动。**
|
||||
>
|
||||
> **2026-09-17 Phase 18 复验关闭:** EPIC-P18-V11 已完成。复验发现申诉整条真实 DB 路径因测试库缺 `appeals` 表而**零覆盖**,`AppealServiceTest` 9 例中 6 例是 `assertTrue(true)` 假绿(覆盖的恰是 Gate 四条不变量);且 `escalateAppeal` 向 `operatorId` 发用户通知会撞 `notifications.user_id → users(id)` FK(operators 与 users 不同表)→ PG 上升级 100% 失败;`appealType` 未校验生产 `chk_appeal_type` 白名单。另修复 escalated 死胡同、`processAppeal` 先读后写并发竞态、三处查询漏软删过滤、硬编码 `resolved`、证据字段契约。共修复 **8 处真实缺陷**(含 1 处必崩级测试盲区、2 处「只在 PG 上炸」),补 `appeals` 表并新增 `AppealPhase18IntegrationTest`(15 例)、删除假绿 9 例,负向验证证明 Gate 守卫有牙。全量回归 **494 tests 全绿**;admin-web `vue-tsc` EXITCODE=0。
|
||||
> 证据:`docs/Phase-18-V11-Recovery-Completion-Report-2026-09-17.md`。
|
||||
> **Phase 19 需用户确认后启动。**
|
||||
|
||||
> **2026-09-16 状态校正:** Phase 07 UI 已按 2026-09-13 规格重新实现,**首批 11 页于 2026-09-16 通过用户验收(结论:基本达标)**,本文件此前"视觉否决/REOPENED"口径随之关闭。
|
||||
>
|
||||
@@ -141,6 +144,7 @@
|
||||
| Phase 15 活动报名审核签到复验 | 已完成(2026-09-17) | 见 `Phase-15-V11-Recovery-Completion-Report-2026-09-17.md`;修复 **16 处缺陷**(**D1 签到码一生成即作废**:`generateCheckinToken` 把 token 写 `"used"` 而扫码端见 `"used"` 即拒 → 扫码 100% 失败;**D9 取消/拒绝后永远无法再报名**:撞生产 `UNIQUE(activity_id,user_id)`,测试库缺该约束故零覆盖 → 改复用记录;D2 重复签到竞态、D3 签到无数据范围、D4 手工签到无校验、D5 统计 NPE+语义错、D6 非法 token 500、D7 签名非恒定时间比较、D10 后台审核列表无 pending、D11 报名窗口不生效、D12 取消无审计、D13 审核/取消非原子、D14 容量分组不参与扣减)+ **2 处前端契约缺陷**(admin-web 无签到工作台、小程序无取消报名入口);新增 V040 `activity_staff_assignments` 落实 INV-020 活动数据范围;**3 处测试保真度缺陷**(测试库缺 UNIQUE+2 CHECK、3 条 `assertTrue(true)` 假绿、表未入必选集合);新增 `ActivityPhase15Test`(21),`CheckinServiceTest` 假绿改真实守卫(5),约束守卫 +2,迁移基线 39→40;一轮负向验证 3 处注入 → 8 条用例精准变红并还原;admin-web `vue-tsc` 0 error + `vite build` 通过;后端 **474 tests 全绿**;**Phase 16 尚未获准** |
|
||||
| Phase 16 活动互选二阶段同意复验 | 已完成(2026-09-17) | 见 `Phase-16-V11-Recovery-Completion-Report-2026-09-17.md`;修复 **7 处缺陷 + 1 处隐藏编译缺陷 + 1 处测试保真度缺陷**(**D1 双确认后根本不建 Match/会话(Gate 缺陷·INV-014/015)**、**D3 Phase 15 提交态引用未定义 `reopenEpisode` 致整个模块不编译("474 绿"为假象)**、D4 INV-006 单向意向泄露、D5 复用/新建 Match 后缺会话);统一双实现到 `RelationshipService.confirmActivityInterest`;`MatchMapper` 补 `reopenEpisode`;测试库补 `activity_mutual_connection_consents` 表并移入必选集合,`MigrationValidationTest` 基线 40→41;新建 `ActivityMutualConsentServiceTest`(7 用例);`ChatPhase09IntegrationTest:344` 断言对齐 AGENTS.md #20 复用语义;新增 V041 `expires_at`+`match_id`;负向验证 2 用例精准变红并还原;后端 **481 tests 全绿**;**Phase 17 尚未获准** |
|
||||
| Phase 17 举报与风控复验 | 已完成(2026-09-17) | 见 `Phase-17-V11-Recovery-Completion-Report-2026-09-17.md`;修复 **6 处真实缺陷**(**D1(Critical,必崩级盲区)测试库缺 `reports`/`risk_events`/`risk_actions` 三表 → 风控/举报真实 DB 路径零覆盖,`ReportServiceTest` 全 `assertTrue(true)` 假绿**;**D3(High)敏感访问(查看他人风险画像/举报)无审计(违反 AGENTS.md #8)**;D4 审计缺 `ip`/`userAgent`;D5 `findByUserId` 未过滤软删;D2 人工标记无审计;**D6 `javax`→`jakarta` 隐藏编译缺陷 + `risk_events` 漏 `updated_at` 列**);补齐三表并移出白名单入 `REQUIRED_IN_TEST_SCHEMA`;新增 `AuditLogService.recordSensitiveAccess` 并接线三端点;新增 `ReportAndRiskPhase17IntegrationTest`(7 用例);负向验证(注释 getReportDetail 审计 → D3 守卫精准变红并还原);后端 **488 tests 全绿**;**Phase 18 尚未获准** |
|
||||
| Phase 18 申诉复验 | 已完成(2026-09-17) | 见 `Phase-18-V11-Recovery-Completion-Report-2026-09-17.md`;修复 **8 处真实缺陷**(**D1(Critical,必崩级盲区)测试库缺 `appeals` 表 + `AppealServiceTest` 9 例中 6 例 `assertTrue(true)` 假绿 → 申诉真实 DB 路径零覆盖**;**D2(High,只在 PG 上炸)`escalateAppeal` 向 `operatorId` 发用户通知撞 `notifications.user_id → users(id)` FK → 升级事务整体回滚**;**D3(High,只在 PG 上炸)`appealType` 未校验生产 `chk_appeal_type` 白名单**;D4 escalated 死胡同(前后端均无法再决策);D5 `processAppeal` 先读后写并发竞态;D6 三处查询漏 `deleted_at` 过滤;D7 硬编码 `resolved`;D8 证据字段契约 `evidenceFileIds`);补 `appeals` 表并移出白名单入 `REQUIRED_IN_TEST_SCHEMA`;新增原子认领 `markReviewingIfProcessable`;新增 `AppealPhase18IntegrationTest`(15 用例),删除假绿 `AppealServiceTest`(9 用例);负向验证(误伤其他处置 → Gate 用例精准变红并还原);后端 **494 tests 全绿**;admin-web `vue-tsc` EXITCODE=0;**Phase 19 尚未获准** |
|
||||
| UI-CLEANUP-20260913 | 已完成 | 废弃 UI 代码、冲突规范、旧计划和无效验收证据已清理;`ui-document-baseline-contracts` 锁定唯一视觉入口 |
|
||||
| UI-ADMIN-20260913 | 已完成(待桌面端目视复验) | 管理端全局框架与工作台已按规格交付;18 modules + 22 routes,vue-tsc 与 vite build 全绿 |
|
||||
| UI-MINIAPP-20260913 | 已完成 | 小程序 11 个核心页面已重构;11/11 页 TS-only,2026-09-16 通过用户验收 |
|
||||
@@ -180,7 +184,7 @@
|
||||
| EPIC-P15-V11 | P15 | 活动签到回归测试 | P1 |
|
||||
| EPIC-P16-V11 | P16 | 活动互选二阶段同意重构 | P0 | ✅
|
||||
| EPIC-P17-V11 | P17 | 公益服务风险来源扩展 | P0 | ✅
|
||||
| EPIC-P18-V11 | P18 | 申诉保持 | P1 |
|
||||
| EPIC-P18-V11 | P18 | 申诉保持 | P1 | ✅
|
||||
| EPIC-P19-V11 | P19 | 善缘丹阳综合运营后台 | P1 |
|
||||
| EPIC-P20-V11 | P20 | 公益项目数据统计扩展 | P1 |
|
||||
| EPIC-P21-V11 | P21 | 账户生命周期增量 | P1 |
|
||||
@@ -507,16 +511,22 @@
|
||||
- 证据:`docs/Phase-17-V11-Recovery-Completion-Report-2026-09-17.md`
|
||||
---
|
||||
|
||||
## EPIC-P18-V11: 申诉保持 ✅ 已验证(V1.0基础保持不变)
|
||||
## EPIC-P18-V11: 申诉保持 ✅ 已完成(2026-09-17 KEEP-VERIFY 复验关闭)
|
||||
|
||||
### Verification
|
||||
- V013 appeals 表结构完整(ban_appeal / suspend_appeal)
|
||||
- AppealService: FeatureFlag 控制 + 幂等性 + 可申诉限制(仅 ban/suspend)
|
||||
### Verification(2026-09-17 复验后校正)
|
||||
- V013 `appeals` 生产 DDL 完整(`chk_appeal_status` / `chk_appeal_type`);但**测试库此前完全缺该表**(只在 parity 可选白名单里 = 无人测),已补入 `schema-h2.sql` 并移入 `REQUIRED_IN_TEST_SCHEMA`。
|
||||
- AppealService: FeatureFlag 控制 + 幂等性 + 可申诉限制(仅 ban/suspend)——复验后补 `appealType` 白名单校验与处置类型一致性。
|
||||
- AppealController: 用户端 account-status / submit / my
|
||||
- AppealOperatorController: 后台端 pending / detail / process / stats
|
||||
- Miniapp: appeal 页面已注册
|
||||
- 申诉通过 → 自动解除对应 risk_action(status→revoked)
|
||||
- 申诉被拒 → 维持原处置
|
||||
- Miniapp: appeal 页面已注册(证据字段名 `evidenceFileIds` 已与后端 DTO 对齐)
|
||||
- 申诉通过 → 自动解除**对应** risk_action(`status → resolved`,只解除目标处置;其他仍有效处置不受影响)。
|
||||
⚠️ 旧注"status→revoked"**不实**:`Constants.RISK_ACTION_STATUS_REVOKED` 从无使用点,且不在生产 `chk_risk_action_status`(active/resolved/expired)白名单内;实现使用 `resolved`。
|
||||
- 申诉被拒 → 维持原处置(`risk_action` 不变更)
|
||||
|
||||
### 2026-09-17 KEEP-VERIFY 复验结论
|
||||
- 修复 **8 处真实缺陷**:D1 零覆盖(缺 `appeals` 表 + 6 处 `assertTrue(true)` 假绿);**D2 `escalateAppeal` 向 operator 发用户通知撞 `notifications.user_id → users(id)` FK → PG 上升级 100% 失败**;**D3 `appealType` 未校验 `chk_appeal_type`**;D4 escalated 死胡同;D5 `processAppeal` 先读后写并发竞态;D6 三处查询漏软删过滤;D7 硬编码 `resolved`;D8 证据字段契约。
|
||||
- 新增 `AppealPhase18IntegrationTest`(15 例),删除假绿 `AppealServiceTest`(9 例);负向验证证明 Gate 守卫有牙;后端 **494 tests 全绿**;admin-web `vue-tsc` EXITCODE=0。
|
||||
- 证据:`docs/Phase-18-V11-Recovery-Completion-Report-2026-09-17.md`
|
||||
|
||||
### 新增文件
|
||||
- V025__extend_risk_sources.sql — 扩展 risk_events.event_type CHECK + risk_actions.action_type
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
# Phase 18 — 申诉 KEEP-VERIFY 复验完成报告
|
||||
|
||||
**日期**:2026-09-17
|
||||
**范围**:申诉(appeal 模块、Miniapp appeal 页面、后台 AppealCenter)
|
||||
**纪律**:KEEP-VERIFY。历史 ✅ 不可信;测试覆盖 ≠ 运行时路径覆盖;逐条核对运行时路径与生产约束;发现真实缺陷即修;补测试保真度;守卫必须解析真实 DDL;离线容器真实编译+执行;每阶段出报告后停止等确认。
|
||||
|
||||
---
|
||||
|
||||
## 一、结论
|
||||
|
||||
**Gate 达成,复验关闭。**
|
||||
|
||||
| Gate | 结果 |
|
||||
|---|---|
|
||||
| 申诉通过只解除目标处置 | ✅ `approveAppeal` 只把 `relatedActionId` 指向的处置置 `resolved`;用户其他仍有效的处置保持 `active`(负向验证已证守卫有牙) |
|
||||
| 拒绝后原处置保持有效 | ✅ `rejectAppeal` 不变更 `risk_action`,原处置维持 `active`,账号仍受限 |
|
||||
| 首发由 Feature Flag 控制 | ✅ `features.appeal-enabled`(dev/docker 均 `false`);关闭时 `submitAppeal` 抛"申诉功能暂未开放" |
|
||||
|
||||
**本轮修复真实缺陷 8 处(含 1 处必崩级测试盲区、2 处「只在 PG 上炸」的生产故障)**,新增集成测试 15 例,删除假绿测试 9 例。
|
||||
|
||||
- 全量回归:**494 tests / 0 failures / 0 errors / 3 skipped / BUILD SUCCESS**(约 4.4 分)
|
||||
- 基线 488 → 494,**净 +6**(−9 假绿 `AppealServiceTest`,+15 `AppealPhase18IntegrationTest`)
|
||||
- 负向验证:临时让 `approveAppeal` 误伤用户其他有效处置 → `approveResolvesOnlyTargetActionAndKeepsOthersActive` **精准变红**,已还原
|
||||
- admin-web `vue-tsc --noEmit` **EXITCODE=0**
|
||||
|
||||
---
|
||||
|
||||
## 二、本轮最关键发现(必记)
|
||||
|
||||
### F1:申诉整条真实 DB 路径「零覆盖」盲区(必崩级)
|
||||
`appeals` 表只列在 `EntityMigrationParityTest.TEST_SCHEMA_OPTIONAL_TABLES` 白名单里,而 H2 `schema-h2.sql` **根本没有该表** → `submitAppeal` / `processAppeal` / `approve` / `reject` / `escalate` 整条真实 DB 路径**零覆盖**。
|
||||
更严重的是 `AppealServiceTest`:9 个用例中 **6 个是 `assertTrue(true)` 假绿**,而它们"覆盖"的恰好是 Gate 四条不变量(仅 ban/suspend 可申诉、幂等、通过解除、拒绝维持);另有 1 个用 `@Mock` 断言 Mockito 默认值(`testFeatureFlagDefaultDisabled` 测的是 mock 不是真实默认值)。
|
||||
**这是「白名单 = 无人测 + 占位假绿」模式的第八次复现。**
|
||||
|
||||
### F2:`escalate` 向 operator 发用户通知 → 撞 FK(只在 PG 上炸)
|
||||
`escalateAppeal` 调用 `notificationService.send(operatorId, ...)`,但 `operators` 与 `users` 是**两张独立表**(V001),`notifications.user_id` 有 `FOREIGN KEY ... REFERENCES users(id)`(V004,从未移除)→ 在 PG 上必然 FK 违约,`@Transactional` 整体回滚 → **升级人工 100% 失败(500)**。测试库 notifications 无 FK,故零覆盖。
|
||||
|
||||
### F3:`appealType` 未校验白名单(只在 PG 上炸)
|
||||
`submitAppeal` 直接透传 `dto.getAppealType()`,生产 `chk_appeal_type CHECK (appeal_type IN ('ban_appeal','suspend_appeal'))` 会拒绝非法值 → 500。测试库无 `appeals` 表故零覆盖。
|
||||
|
||||
---
|
||||
|
||||
## 三、真实缺陷清单(对应 Gate)
|
||||
|
||||
| 编号 | 类别 | 缺陷 | 修复 |
|
||||
|---|---|---|---|
|
||||
| D1 | 测试盲区/必崩级 | `appeals` 表缺失 + `AppealServiceTest` 6 处 `assertTrue(true)` 假绿 → 申诉真实 DB 路径零覆盖 | 补 `appeals` 表(对齐 V013,去外键留两 CHECK);移出白名单入 `REQUIRED_IN_TEST_SCHEMA`;删除假绿测试,新建 `AppealPhase18IntegrationTest`(15 例) |
|
||||
| D2 | 生产故障(PG) | `escalateAppeal` 向 `operatorId` 发用户通知 → 撞 `notifications.user_id → users(id)` FK → 升级事务回滚 | 移除非法通知,升级后进入后台待办队列由风控员决策 |
|
||||
| D3 | 生产故障(PG) | `appealType` 未校验 `chk_appeal_type` 白名单;且未与处置类型一致性校验 | 校验白名单 + 必须与处置类型匹配;缺省按处置类型推导 |
|
||||
| D4 | 功能死胡同 | `escalated` 状态无法再处理(`processAppeal` 仅接受 pending/reviewing)→ 升级后申诉永久滞留;前端 `AppealCenterView` 处理按钮也仅 `pending` | 后端原子认领放行 `escalated`;`getPendingAppeals` 返回 pending+escalated;前端放开处理入口 |
|
||||
| D5 | 并发 | `processAppeal` 先读后写无原子守卫 → 两运营并发处理同一申诉可相互覆盖(approve/reject 竞态) | 新增 `markReviewingIfProcessable` 原子认领(`WHERE status IN ('pending','escalated')`),仅赢家继续 |
|
||||
| D6 | 数据一致性 | `AppealMapper.findByUserId` / `findPending` / `findByStatusPaged` 未过滤 `deleted_at`(`countByStatus` 有) | 三处补 `deleted_at IS NULL` |
|
||||
| D7 | 可维护性 | `approveAppeal` 硬编码 `"resolved"` | 改用 `Constants.RISK_ACTION_STATUS_RESOLVED` |
|
||||
| D8 | 前后端契约 | miniapp 传 `evidenceFileIds`,后端只读 `evidence` → 证据字段名不匹配、永为 null | DTO 增 `evidenceFileIds`,服务层序列化映射进 `evidence` |
|
||||
|
||||
> 说明:`RISK_ACTION_STATUS_REVOKED`(`"revoked"`)在 `Constants` 中定义但**从无使用点**,且不在生产 `chk_risk_action_status`(`active/resolved/expired`)白名单内;`approveAppeal` 实际使用 `resolved`(正确)。JIRA 旧注"申诉通过 → status→revoked"与实现不符,已在文档中校正为 `resolved`。
|
||||
|
||||
---
|
||||
|
||||
## 四、负向验证(守卫有牙)
|
||||
|
||||
- 临时在 `approveAppeal` 中追加"把用户所有 active 处置一并置 resolved"(模拟"绕过其他处置")→ 定向运行 `AppealPhase18IntegrationTest` → **恰好 1 条用例精准变红**:`approveResolvesOnlyTargetActionAndKeepsOthersActive` 报 `expected: <active> but was: <resolved>`("其他仍有效的处置不得被解除(Gate)")。已还原,`grep NEG-VAL` = 0。
|
||||
|
||||
---
|
||||
|
||||
## 五、证据(实测,非声明)
|
||||
|
||||
- **全量回归**:`494 tests / 0 failures / 0 errors / 3 skipped / BUILD SUCCESS`(离线容器 `danyang-match-backend-test` `mvn -B -o test`,约 4.4 分)。
|
||||
- **定向编译验证**:`mvn -B -o test-compile` 通过(284 主源 + 78 测试源)。
|
||||
- **前端类型检查**:admin-web `vue-tsc --noEmit` EXITCODE=0。
|
||||
- **新增测试**:`backend/src/test/java/com/danyang/match/platform/appeal/AppealPhase18IntegrationTest.java`(15 例:真实落库/审计、仅 ban/suspend 可申诉、幂等、**Gate A 通过只解除目标处置**、**Gate B 拒绝维持原处置**、原子认领唯一赢家、二次处理被拒、escalate 后可再决策、appealType 白名单/一致性/缺省推导、证据字段映射、软删过滤、Feature Flag 关闭拦截)。
|
||||
- **删除**:`backend/src/test/java/com/danyang/match/platform/appeal/AppealServiceTest.java`(6 假绿 + mock 默认值断言)。
|
||||
|
||||
---
|
||||
|
||||
## 六、改动文件清单
|
||||
|
||||
**主代码(src/main)**
|
||||
- `appeal/service/AppealService.java` — 白名单校验、原子认领、escalate 去非法通知、常量替换、证据映射、软删过滤、escalated 可再决策。
|
||||
- `appeal/mapper/AppealMapper.java` — 三处补 `deleted_at IS NULL`;新增 `findPendingOrEscalated` 与 `markReviewingIfProcessable`。
|
||||
- `appeal/dto/AppealSubmitDTO.java` — 新增 `evidenceFileIds`(对齐 miniapp 契约)。
|
||||
- `shared/constant/Constants.java` — 新增 `APPEAL_TYPE_BAN` / `APPEAL_TYPE_SUSPEND`。
|
||||
|
||||
**测试(src/test)**
|
||||
- `appeal/AppealPhase18IntegrationTest.java` — 新增 15 例。
|
||||
- `appeal/AppealServiceTest.java` — 删除(假绿)。
|
||||
- `deployment/EntityMigrationParityTest.java` — `appeals` 移出白名单、入 `REQUIRED_IN_TEST_SCHEMA`(Phase 18 注释)。
|
||||
- `resources/schema-h2.sql` — 补 `appeals` 表。
|
||||
|
||||
**前端**
|
||||
- `admin-web/src/modules/appeal/AppealCenterView.vue` — escalated 申诉放开处理入口(列表按钮 + 详情决策表单)。
|
||||
|
||||
**文档**
|
||||
- `docs/STATUS.md`、`docs/JIRA_BACKLOG.md`、`docs/善缘丹阳 V1.1 客户需求基线增量开发与修复计划.md` 的 Phase 18 段。
|
||||
- `docs/Phase-18-V11-Recovery-Completion-Report-2026-09-17.md`(本报告)。
|
||||
- 技能 `danyang-platform-verification` 同步更新。
|
||||
|
||||
---
|
||||
|
||||
## 七、下一步
|
||||
|
||||
**Phase 19(统一运营后台 + 用户端公益服务页面 — ADD/REFACTOR)未自动进入,等待用户确认后启动。** 是否继续?
|
||||
+24
-3
@@ -1,8 +1,8 @@
|
||||
# 项目状态跟踪 — STATUS.md
|
||||
|
||||
> **2026-09-17 当前有效结论:`V1.1 INCREMENTAL RECOVERY PHASE 17 COMPLETE / GIT CLEAN`。**
|
||||
> **2026-09-17 当前有效结论:`V1.1 INCREMENTAL RECOVERY PHASE 18 COMPLETE / GIT CLEAN`。**
|
||||
>
|
||||
> 阶段 0(在途收口)、阶段 1(小程序 C 组 + discover 筛选重设计)、阶段 2(管理端 Vue 新框架适配)、阶段 3(CapabilityService 独立化)、阶段 4(双端总验收)全部完成。三端构建通过、**13/13 契约全绿**、核心 11 页面**全部 TS-only(11/11)**。**UI 重设计首批 11 页已于 2026-09-16 通过用户验收(结论:基本达标)**;`/me/*` 前后端契约同轮收敛,17 个 404 调用已恢复。**Phase 08(Like / Match / Block)、Phase 09(Text Chat)、Phase 10(Image & Chat Safety)、Phase 11(Notification)、Phase 12(Relationship Progress)、Phase 13(公益红娘转介)、Phase 13A(婚恋咨询与婚前辅导)、Phase 14(多类型公益活动)、Phase 15(活动报名、审核与签到)、Phase 16(活动互选与双方开启沟通确认)、Phase 17(举报与风控)复验已关闭**,十一轮共修复 77 处真实缺陷(含 2 处隐藏编译缺陷),并各自补齐测试保真度与守卫。**Phase 18 需用户确认后启动。**
|
||||
> 阶段 0(在途收口)、阶段 1(小程序 C 组 + discover 筛选重设计)、阶段 2(管理端 Vue 新框架适配)、阶段 3(CapabilityService 独立化)、阶段 4(双端总验收)全部完成。三端构建通过、**13/13 契约全绿**、核心 11 页面**全部 TS-only(11/11)**。**UI 重设计首批 11 页已于 2026-09-16 通过用户验收(结论:基本达标)**;`/me/*` 前后端契约同轮收敛,17 个 404 调用已恢复。**Phase 08(Like / Match / Block)、Phase 09(Text Chat)、Phase 10(Image & Chat Safety)、Phase 11(Notification)、Phase 12(Relationship Progress)、Phase 13(公益红娘转介)、Phase 13A(婚恋咨询与婚前辅导)、Phase 14(多类型公益活动)、Phase 15(活动报名、审核与签到)、Phase 16(活动互选与双方开启沟通确认)、Phase 17(举报与风控)、Phase 18(申诉)复验已关闭**,十二轮共修复 85 处真实缺陷(含 2 处隐藏编译缺陷),并各自补齐测试保真度与守卫。**Phase 19 需用户确认后启动。**
|
||||
|
||||
最后更新: 2026-09-17 — 分支 `main`(`feat-ui-v11` 保留为冗余锚点)
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
| 阶段 3 CapabilityService 独立化 | 已完成 | New `capability` 模块:CapabilityService(getCapabilities + 公共判断方法)+ CapabilityController(GET /api/v1/capabilities);HomeApplicationService 完全委托 |
|
||||
| 阶段 4 双端总验收 | **已完成**(2026-09-14) | 三端构建全绿 + 13/13 契约全绿 + 源码健康检查通过;验收报告见 docs/superpowers/plans/2026-09-14-phase4-acceptance-report.md |
|
||||
| Flyway 迁移 | V001~V039 | V028~V038 已纳入 STATUS;V037 为 Phase 12 新增(实体列与生产迁移对齐,23 表 50 列);V038 为 Phase 13 新增(`matchmaker_recommendations.status` + `matchmaker_services.service_type` 的 CHECK 白名单扩展);V039 为 Phase 13A 新增(counseling 幂等键 + 三个 CHECK 白名单显式固化) |
|
||||
| Backend 全量回归 | **已清偿**(2026-09-17) | 离线容器(`danyang-match-backend-test`)`mvn -o test` 实测:**488 tests / 0 failures / 0 errors / 3 skipped / BUILD SUCCESS**(约 4 分);含 `CanonicalRouteContractTest`、`ChatContractJsonTest`、`MeRouteContractTest`、`DiscoverBlockInvisibilityIntegrationTest`、`ChatPhase09IntegrationTest`、`ChatWebSocketHandlerTest`、`PhotoUploadValidationTest`、`NotificationPhase11IntegrationTest`、`NotificationTemplateTypeGuardTest`、`NotificationPrivacyCopyTest`、`EntityMigrationParityTest`、`RelationshipProgressPhase12Test`、`HomePremaritalGuidancePhase12Test`、`MatchmakerReferralPhase13Test`、`MatchmakerServiceTest`、`ReportAndRiskPhase17IntegrationTest`(Phase 17 新增 7 例:举报写 reports+abuse_report 风险事件、三来源接入统一 RiskEvent、处置封禁写 risk_actions、人工标记审计、敏感访问审计、findByUserId 软删过滤、最小可见) |
|
||||
| Backend 全量回归 | **已清偿**(2026-09-17) | 离线容器(`danyang-match-backend-test`)`mvn -o test` 实测:**494 tests / 0 failures / 0 errors / 3 skipped / BUILD SUCCESS**(约 4.4 分);含 `CanonicalRouteContractTest`、`ChatContractJsonTest`、`MeRouteContractTest`、`DiscoverBlockInvisibilityIntegrationTest`、`ChatPhase09IntegrationTest`、`ChatWebSocketHandlerTest`、`PhotoUploadValidationTest`、`NotificationPhase11IntegrationTest`、`NotificationTemplateTypeGuardTest`、`NotificationPrivacyCopyTest`、`EntityMigrationParityTest`、`RelationshipProgressPhase12Test`、`HomePremaritalGuidancePhase12Test`、`MatchmakerReferralPhase13Test`、`MatchmakerServiceTest`、`ReportAndRiskPhase17IntegrationTest`、`AppealPhase18IntegrationTest`(Phase 18 新增 15 例:申诉真实落库+审计、仅 ban/suspend 可申诉、幂等、**Gate A 通过只解除目标处置**、**Gate B 拒绝维持原处置**、原子认领唯一赢家、二次处理被拒、escalate 后可再决策、appealType 白名单/一致性/缺省推导、证据字段映射、软删过滤、Feature Flag 关闭拦截) |
|
||||
| 路由/字段契约收敛 | **已完成**(2026-09-15) | 字段:会话 `target*` + `sentAt` + `relationStatus`(commit `687a156`);路径:采用 2026-09-14 canonical(`/likes` `/passes` `/blocks` `/matches`,缘分保留 `/relations/fate/*`),旧路径与 `success` 桩实现已移除;裁决由 `CanonicalRouteContractTest` 锁定 |
|
||||
| `/me/*` 路径族 | **已完成**(2026-09-16) | 小程序按 REST 规范(§16 / §22-§40 / §80 / §83-§84)调用 `/me/*`,后端此前**无 `/me` 基路径**致 17 个调用 404。新增 `MeController` 委托既有 service 暴露规范路径,旧路径全部保留,小程序三条历史写法(`/me/privacy-settings`、`/me/photos/{id}/main`、`/me/appeals`)一并兼容;由 `MeRouteContractTest` 锁定 |
|
||||
| Admin Web | **验证通过** | 2026-09-14 阶段 4:vue-tsc ✅、vite build ✅ 11.68s;18 modules 齐全;壳层+工作台通过参考图 QA |
|
||||
@@ -47,6 +47,7 @@
|
||||
| 1.1.0-rc.7 | 2026-09-17 | Phase 15 复验关闭(活动报名/审核/签到,16 处缺陷:含**签到码一生成即作废**、**取消后永远无法再报名**两处必崩;3 处测试保真度);新增 V040 `activity_staff_assignments` 与 `ActivityCheckinView` 签到工作台;全量回归 **474 tests** 全绿 |
|
||||
| 1.1.0-rc.8 | 2026-09-17 | Phase 16 复验关闭(活动互选与双方开启沟通确认,7 处缺陷 + 1 处隐藏编译缺陷 + 1 处测试保真度:含**双确认后根本不建 Match/会话(Gate 缺陷)**、**Phase 15 提交态引用未定义 `reopenEpisode` 致模块不编译("474 绿"为假象)**、**INV-006 单向意向泄露**、**复用/新建 Match 后缺会话**;统一双实现到 `RelationshipService.confirmActivityInterest`;新增 V041 `expires_at`+`match_id`;新建 `ActivityMutualConsentServiceTest`(7 用例);全量回归 **481 tests** 全绿 |
|
||||
| 1.1.0-rc.9 | 2026-09-17 | Phase 17 复验关闭(举报与风控,6 处真实缺陷:含**D1 测试库缺 reports/risk_events/risk_actions 三表致风控/举报真实 DB 路径零覆盖(必崩级盲区)+ ReportServiceTest 假绿**、**D3 敏感访问(查看他人风险画像/举报)无审计(AGENTS.md #8)**、**D4 审计缺 ip/userAgent**、**D5 findByUserId 未过滤软删**、**D2 人工标记无审计**、**D6 javax→jakarta 隐藏编译缺陷 + risk_events 漏 updated_at**);补齐三表并移出白名单入 REQUIRED_IN_TEST_SCHEMA;新增 `AuditLogService.recordSensitiveAccess` 并接线三端点;新增 `ReportAndRiskPhase17IntegrationTest`(7 用例);全量回归 **488 tests** 全绿 |
|
||||
| 1.1.0-rc.10 | 2026-09-17 | Phase 18 复验关闭(申诉,8 处真实缺陷:含**D1 测试库缺 appeals 表 + AppealServiceTest 6 处 assertTrue(true) 假绿 → 申诉真实 DB 路径零覆盖**、**D2 escalate 向 operator 发用户通知撞 notifications.user_id→users(id) FK → PG 上升级 100% 失败**、**D3 appealType 未校验生产 chk_appeal_type**、D4 escalated 死胡同、D5 processAppeal 先读后写并发竞态、D6 三处查询漏软删过滤、D7 硬编码 resolved、D8 证据字段契约 evidenceFileIds);补 appeals 表并移出白名单入 REQUIRED_IN_TEST_SCHEMA;新增原子认领 `markReviewingIfProcessable`;新增 `AppealPhase18IntegrationTest`(15 用例),删除假绿 `AppealServiceTest`(9 用例);全量回归 **494 tests** 全绿;admin-web `vue-tsc` EXITCODE=0 |
|
||||
|
||||
---
|
||||
|
||||
@@ -263,6 +264,26 @@
|
||||
> 假绿掩盖盲区;且敏感访问(查看他人风险画像/举报)此前**无审计**(违反 AGENTS.md #8 默认拒绝 + 查看需审计)。已修复 6 处真实缺陷
|
||||
> (含 1 处必崩级测试盲区、1 处隐藏 jakarta 编译缺陷),补齐三表并新增 7 例集成测试,负向验证证明守卫有牙。报告:`docs/Phase-17-V11-Recovery-Completion-Report-2026-09-17.md`
|
||||
|
||||
### Phase 18 — 申诉 ✅ 已完成(2026-09-17 KEEP-VERIFY 复验通过)
|
||||
| 任务 | 状态 |
|
||||
|------|------|
|
||||
| **D1(Critical,必崩级测试盲区)** `appeals` 表缺失 + `AppealServiceTest` 9 例中 6 例 `assertTrue(true)` 假绿 → 申诉真实 DB 路径零覆盖 | ✅ 补 `appeals` 表(对齐 V013,去外键留 `chk_appeal_status`/`chk_appeal_type`);移出白名单入 `REQUIRED_IN_TEST_SCHEMA`;删除假绿测试,新建 `AppealPhase18IntegrationTest`(15 例) |
|
||||
| **D2(High,生产故障 · 只在 PG 上炸)** `escalateAppeal` 向 `operatorId` 发用户通知 → 撞 `notifications.user_id → users(id)` FK(operators 与 users 不同表)→ 升级事务整体回滚 | ✅ 移除非法通知;升级后进入后台待办队列由风控员决策 |
|
||||
| **D3(High,生产故障 · 只在 PG 上炸)** `appealType` 未校验 `chk_appeal_type` 白名单 | ✅ 校验白名单 + 必须与处置类型一致;缺省按处置类型推导 |
|
||||
| **D4(Medium,功能死胡同)** `escalated` 无法再处理(后端只收 pending/reviewing;前端按钮仅 pending) | ✅ 后端原子认领放行 escalated;`getPendingAppeals` 返回 pending+escalated;前端放开处理入口 |
|
||||
| **D5(Medium,并发)** `processAppeal` 先读后写无原子守卫 → 并发双处理相互覆盖 | ✅ 新增 `markReviewingIfProcessable` 原子认领,仅赢家继续 |
|
||||
| **D6(Low,一致性)** `findByUserId`/`findPending`/`findByStatusPaged` 未过滤 `deleted_at` | ✅ 三处补 `deleted_at IS NULL` |
|
||||
| **D7(Low)** `approveAppeal` 硬编码 `"resolved"` | ✅ 改用 `Constants.RISK_ACTION_STATUS_RESOLVED` |
|
||||
| **D8(Low,前后端契约)** miniapp 传 `evidenceFileIds`、后端只读 `evidence` | ✅ DTO 增 `evidenceFileIds`,服务层序列化映射进 `evidence` |
|
||||
| 负向验证(误伤其他处置 → Gate 用例精准变红并还原) | ✅ 守卫有牙 |
|
||||
| admin-web `vue-tsc --noEmit` EXITCODE=0 | ✅ |
|
||||
| 全量回归 **494 tests 全绿** | ✅ |
|
||||
|
||||
> **2026-09-17 复验结论(KEEP-VERIFY)**:历史 ✅ 不可信。申诉整条真实 DB 路径因测试库缺 `appeals` 表而**零覆盖**,`AppealServiceTest` 的
|
||||
> 6 处 `assertTrue(true)` 假绿恰好掩盖 Gate 四条不变量;且 `escalate` 向 operator 发用户通知会撞 `notifications.user_id → users(id)` FK
|
||||
> (PG 上升级 100% 失败)、`appealType` 未校验生产 CHECK 白名单。已修复 8 处真实缺陷(含 1 处必崩级测试盲区、2 处「只在 PG 上炸」的生产故障),
|
||||
> 补齐 `appeals` 表并新增 15 例集成测试、删除 9 例假绿,负向验证证明 Gate 守卫有牙。报告:`docs/Phase-18-V11-Recovery-Completion-Report-2026-09-17.md`
|
||||
|
||||
### Phase 13A — 公益婚恋咨询与婚前辅导(核心链路)✅ 已完成(2026-09-17 KEEP-VERIFY/EXTEND 复验通过)
|
||||
| 任务 | 状态 |
|
||||
|------|------|
|
||||
|
||||
@@ -463,15 +463,17 @@ D2——`RelationshipService.createMatch` 不写 episode/lastActivityAt,与 `M
|
||||
**复验结论:** 修复 6 处真实缺陷(D1 测试库缺三表必崩级盲区、D2 人工标记无审计、D3 敏感访问无审计、D4 审计缺 ip/ua、D5 findByUserId 未过滤软删、D6 jakarta 编译缺陷+risk_events 漏 updated_at);新增集成测试 7 例;全量回归 488 全绿。
|
||||
**报告:** `docs/Phase-17-V11-Recovery-Completion-Report-2026-09-17.md`。**Phase 18 需用户确认后启动。**
|
||||
|
||||
### Phase 18:申诉 — KEEP-VERIFY
|
||||
### Phase 18:申诉 — KEEP-VERIFY ✅ 已完成(2026-09-17 KEEP-VERIFY 复验通过)
|
||||
|
||||
**Files:** appeal 模块、Miniapp appeal 页面和后台 AppealCenter。
|
||||
|
||||
- [ ] 回归 ban/suspend 申诉、幂等处理、审批和解除动作。
|
||||
- [ ] 验证申诉不能绕过仍有效的其他风险处置。
|
||||
- [ ] 是否首发继续由已确认 Feature Flag 控制。
|
||||
- [x] 回归 ban/suspend 申诉、幂等处理、审批和解除动作。
|
||||
- [x] 验证申诉不能绕过仍有效的其他风险处置(负向验证已证守卫有牙)。
|
||||
- [x] 是否首发继续由已确认 Feature Flag 控制(`features.appeal-enabled`,dev/docker 均 false)。
|
||||
|
||||
**Gate:** 申诉通过只解除目标处置;拒绝后原处置保持有效。
|
||||
**Gate:** 申诉通过只解除目标处置 ✅;拒绝后原处置保持有效 ✅。
|
||||
**复验结论:** 修复 8 处真实缺陷(D1 测试库缺 `appeals` 表 + `AppealServiceTest` 6 处假绿 → 零覆盖;**D2 escalate 向 operator 发用户通知撞 `notifications.user_id→users(id)` FK,PG 上升级 100% 失败**;**D3 appealType 未校验生产 `chk_appeal_type` 白名单**;D4 escalated 死胡同;D5 processAppeal 先读后写并发竞态;D6 三处查询漏软删过滤;D7 硬编码 resolved;D8 证据字段契约 evidenceFileIds);新增 `AppealPhase18IntegrationTest`(15 例),删除假绿 `AppealServiceTest`(9 例);全量回归 494 全绿。
|
||||
**报告:** `docs/Phase-18-V11-Recovery-Completion-Report-2026-09-17.md`。**Phase 19 需用户确认后启动。**
|
||||
|
||||
### Phase 19:统一运营后台 + 用户端公益服务页面 — ADD/REFACTOR
|
||||
|
||||
|
||||
Reference in New Issue
Block a user