fix(matchmaker,referral): close Phase 13 KEEP-VERIFY/EXTEND — 7 real defects
KEEP-VERIFY/EXTEND 复验(历史 ✅ 不可信;测试覆盖函数 ≠ 覆盖运行时路径)。
逐条核对运行时路径与生产约束后修复 7 处缺陷,其中 3 处「只在真实 PostgreSQL 上炸」。
D1 (Critical, 生产故障)
matchmaker_recommendations.status 代码写 liked/passed/confirmed_mutual,
而 V009 chk_rec_status 只允许 pending_view/viewed/responded 且从未扩展
→ 真实 PG 上用户接受/跳过推荐必崩。测试库未复刻该 CHECK,故长期静默。
D2 (Critical, 生产故障)
控制器缺省 serviceType="recommendation" 不在 chk_matchmaker_service_type
白名单 → 提交红娘服务必崩。
D1+D2 同修于新增 V038__align_matchmaker_status_constraints.sql
(沿用 V018/V020/V025 的 DROP CONSTRAINT IF EXISTS + 重加白名单惯例)。
D3 (High, 越权写)
MatchmakerServiceService.recommendPartner 无 assignedOperatorId 归属校验,
且可把用户推荐给自己 → 补校验 + 禁止自我推荐。
D4 (High, 越权读)
getServiceDetail 无归属校验且返回完整用户资料(userId/description/
userProfile/followups)→ 签名加 requesterId,按调用者返回完整/脱敏视图。
D5 (High, 越权写)
ReferralService 的 userId 取自请求体且零授权校验 → 新增
assertMatchmakerServesUser,在 referToServiceStation/referToActivity 入口
强制校验存在进行中的服务关系。
D6 (High, 生产故障)
MatchmakerFollowupMapper.findByServiceId 只有声明无 @Select
→ Invalid bound statement (not found),getServiceDetail 必崩。
该缺陷由本轮新增测试首次暴露(历史测试从未调用该路径)→ 补 @Select。
D7 (Medium, 契约缺口)
admin-web 5 处 API 缺口(4 条 404 + C 端端点被运营端误用)+ 小程序缺转介服务层
→ 后端补 GET /operator/matchmaker/recommendations 与
GET /referrals/operator/history 两个运营端端点;前端改路径、
assign 改 POST + body operatorId;新增 miniapp/services/referralService.js。
测试保真度
- 测试库补 matchmaker_services/recommendations/followups 三表(此前该模块零真实覆盖),
约束与 V038 对齐;EntityMigrationParityTest 三表移入 REQUIRED_IN_TEST_SCHEMA;
MigrationValidationTest LATEST_MIGRATION 37 → 38。
- 新增 MatchmakerReferralPhase13Test(17 用例,@SpringBootTest 走真实方法路径 + 真实落库)。
- 重写 MatchmakerServiceTest(6 用例):直接解析 db/migration/V*.sql,按文件名顺序应用
DROP CONSTRAINT IF EXISTS + ADD CONSTRAINT 取"最终有效"白名单,并内置解析器自检。
【关键】首版守卫硬编码白名单副本,注入 D1 后仍全绿(对漂移零反应)→ 必须解析真实 DDL。
- 删除 3 处假测试占位(MatchmakerServiceTest.testMatchmakerCannotLike 的 assertTrue(true);
CoreInvariantsTest INV-007 断言的是状态常量、INV-010 亦为 assertTrue(true)),
已如实改名并注明行为验证位置。
负向验证(两轮)
注入 D1(回退 chk_rec_status 白名单)→ 行为守卫 DataIntegrityViolation
+ 常量守卫 Multiple Failures (3 failures) → Tests run: 23, Failures: 1, Errors: 2
/ BUILD FAILURE,守卫有效性得证。缺陷已还原。
全量回归(离线容器,实测)
[WARNING] Tests run: 391, Failures: 0, Errors: 0, Skipped: 3
[INFO] BUILD SUCCESS / Total time: 04:59 min
Phase 12 基线 371 → 391(净 +20)。
报告:docs/Phase-13-V11-Recovery-Completion-Report-2026-09-16.md
Phase 13A 未自动进入,等待用户确认。
This commit is contained in:
+12
-1
@@ -62,11 +62,22 @@ public class MatchmakerOperatorController {
|
||||
return ApiResponse.ok(matchmakerServiceService.getMyServices(operatorId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 13:红娘名下服务的全部推荐列表(运营端工作台用)。
|
||||
* 补此前 admin-web 调用 `/matchmaker/recommendations/operator` 404 的契约缺口。
|
||||
*/
|
||||
@GetMapping("/recommendations")
|
||||
public ApiResponse<List<Map<String, Object>>> getRecommendations(
|
||||
@AuthenticationPrincipal OperatorPrincipal principal) {
|
||||
return ApiResponse.ok(matchmakerServiceService.getRecommendationsByOperator(principal.id()));
|
||||
}
|
||||
|
||||
@GetMapping("/services/{id}")
|
||||
public ApiResponse<Map<String, Object>> getServiceDetail(
|
||||
@AuthenticationPrincipal OperatorPrincipal principal,
|
||||
@PathVariable String id) {
|
||||
Map<String, Object> detail = matchmakerServiceService.getServiceDetail(id);
|
||||
// Phase 13 D4:传入调用者身份,Service 层按归属决定返回完整/脱敏详情
|
||||
Map<String, Object> detail = matchmakerServiceService.getServiceDetail(id, principal.id());
|
||||
if (detail == null) {
|
||||
return ApiResponse.error(com.danyang.match.platform.shared.enums.ApiResponseCode.NOT_FOUND);
|
||||
}
|
||||
|
||||
+17
-1
@@ -3,11 +3,27 @@ package com.danyang.match.platform.matchmaker.mapper;
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.danyang.match.platform.matchmaker.entity.MatchmakerFollowup;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface MatchmakerFollowupMapper extends BaseMapper<MatchmakerFollowup> {
|
||||
|
||||
List<MatchmakerFollowup> findByServiceId(String serviceId);
|
||||
/**
|
||||
* 某红娘服务的全部跟进记录。
|
||||
*
|
||||
* Phase 13 复验修复(D7):此前该方法**只有声明、没有 @Select**,
|
||||
* MyBatis 无法绑定 → 运行时抛
|
||||
* {@code Invalid bound statement (not found): ...findByServiceId}。
|
||||
* 调用方 {@code MatchmakerServiceService.getServiceDetail} 因此必然 500,
|
||||
* 红娘工作台"查看服务详情"功能完全不可用。
|
||||
*
|
||||
* 该缺陷此前不可见:测试库 schema-h2.sql 曾完全缺失 matchmaker_followups 表,
|
||||
* 没有任何测试能走到这条路径(Phase 13 已补表并新增行为测试)。
|
||||
*/
|
||||
@Select("SELECT * FROM matchmaker_followups WHERE service_id = #{serviceId} "
|
||||
+ "AND deleted_at IS NULL ORDER BY created_at DESC")
|
||||
List<MatchmakerFollowup> findByServiceId(@Param("serviceId") String serviceId);
|
||||
}
|
||||
|
||||
+11
@@ -28,4 +28,15 @@ public interface MatchmakerRecommendationMapper extends BaseMapper<MatchmakerRec
|
||||
|
||||
@Update("UPDATE matchmaker_recommendations SET viewed_at = NOW(), status = 'viewed' WHERE id = #{id} AND status = 'pending_view'")
|
||||
int markViewed(@Param("id") String id);
|
||||
|
||||
/**
|
||||
* Phase 13:某红娘名下服务的全部推荐(运营端列表用)。
|
||||
* 通过 service_id 关联 matchmaker_services.assigned_operator_id 限定为本人名下,
|
||||
* 避免运营端越权看到他人推荐。
|
||||
*/
|
||||
@Select("SELECT r.* FROM matchmaker_recommendations r " +
|
||||
"JOIN matchmaker_services s ON s.id = r.service_id " +
|
||||
"WHERE s.assigned_operator_id = #{operatorId} AND s.deleted_at IS NULL " +
|
||||
"ORDER BY r.created_at DESC")
|
||||
List<MatchmakerRecommendation> findByOperatorId(@Param("operatorId") String operatorId);
|
||||
}
|
||||
|
||||
+10
@@ -41,4 +41,14 @@ public interface MatchmakerServiceMapper extends BaseMapper<MatchmakerServiceRec
|
||||
|
||||
@Select("SELECT * FROM matchmaker_services WHERE assigned_operator_id = #{operatorId} AND status IN ('submitted','assigned','processing') ORDER BY created_at DESC")
|
||||
List<MatchmakerServiceRecord> findAssignedTo(@Param("operatorId") String operatorId);
|
||||
|
||||
/**
|
||||
* Phase 13:查该红娘名下、且服务对象为该用户的进行中服务(转介授权校验用)。
|
||||
* 转介必须挂在真实存在的服务关系上,否则等于任意红娘可代任意用户操作。
|
||||
*/
|
||||
@Select("SELECT * FROM matchmaker_services WHERE assigned_operator_id = #{operatorId} " +
|
||||
"AND user_id = #{userId} AND status IN ('assigned','in_service','waiting_user','submitted') " +
|
||||
"AND deleted_at IS NULL ORDER BY created_at DESC LIMIT 1")
|
||||
Optional<MatchmakerServiceRecord> findActiveByOperatorAndUser(@Param("operatorId") String operatorId,
|
||||
@Param("userId") String userId);
|
||||
}
|
||||
|
||||
+74
-20
@@ -129,6 +129,13 @@ public class MatchmakerServiceService {
|
||||
|
||||
/**
|
||||
* 红娘推荐候选人给用户
|
||||
*
|
||||
* Phase 13 复验修复(D3):补 assigned_operator 归属校验。
|
||||
* 此前只校验 service 存在,任何持 matchmaker.service 权限的 operator
|
||||
* 都能对**任意** serviceId 创建推荐(越权)。与
|
||||
* MatchmakerService.createRecommendation:118-121 的校验保持同一口径。
|
||||
*
|
||||
* AGENTS.md #5:红娘只创建 pending_view 推荐记录,不替用户 Like。
|
||||
*/
|
||||
@Transactional
|
||||
public Map<String, Object> recommendPartner(String serviceId, String operatorId,
|
||||
@@ -136,6 +143,15 @@ public class MatchmakerServiceService {
|
||||
MatchmakerServiceRecord service = serviceMapper.selectById(serviceId);
|
||||
if (service == null) throw new RuntimeException("服务不存在");
|
||||
|
||||
// Phase 13 D3:归属校验 —— 只有该服务的负责人才能推荐
|
||||
if (!operatorId.equals(service.getAssignedOperatorId())) {
|
||||
throw new IllegalStateException("您不是此服务的负责人");
|
||||
}
|
||||
// 不能推荐给自己(服务申请人)
|
||||
if (service.getUserId() != null && service.getUserId().equals(targetUserId)) {
|
||||
throw new IllegalArgumentException("不能推荐用户给自己");
|
||||
}
|
||||
|
||||
MatchmakerRecommendation rec = new MatchmakerRecommendation();
|
||||
rec.setServiceId(serviceId);
|
||||
rec.setUserId(service.getUserId());
|
||||
@@ -266,36 +282,37 @@ public class MatchmakerServiceService {
|
||||
|
||||
/**
|
||||
* 获取服务详情
|
||||
*
|
||||
* Phase 13 复验修复(D4):改为按调用者授权范围返回。
|
||||
* 此前无任何归属校验,任何持 matchmaker.view 的 operator 都能读取
|
||||
* **任意** serviceId 的详情,其中包含该用户的完整资料
|
||||
* (displayName/gender/age/regionName/education/occupation/maritalStatus)→ 越权读。
|
||||
*
|
||||
* 现语义:
|
||||
* - 服务负责人(assigned_operator_id)→ 返回完整详情(含 userProfile + followups)
|
||||
* - 其他 operator(如仅需了解队列的中台人员)→ 返回脱敏详情
|
||||
* (不含 userProfile;followups 正文也不返回,仅给条数)
|
||||
*
|
||||
* @param serviceId 服务 ID
|
||||
* @param requesterId 调用者 operator ID
|
||||
*/
|
||||
public Map<String, Object> getServiceDetail(String serviceId) {
|
||||
public Map<String, Object> getServiceDetail(String serviceId, String requesterId) {
|
||||
MatchmakerServiceRecord service = serviceMapper.selectById(serviceId);
|
||||
if (service == null) return null;
|
||||
|
||||
boolean isOwner = requesterId != null && requesterId.equals(service.getAssignedOperatorId());
|
||||
|
||||
Map<String, Object> detail = new HashMap<>();
|
||||
detail.put("serviceId", service.getId());
|
||||
detail.put("userId", service.getUserId());
|
||||
detail.put("serviceType", service.getServiceType());
|
||||
detail.put("description", service.getDescription());
|
||||
detail.put("status", service.getStatus());
|
||||
detail.put("assignedOperatorId", service.getAssignedOperatorId());
|
||||
detail.put("createdAt", service.getCreatedAt());
|
||||
detail.put("assignedAt", service.getAssignedAt());
|
||||
detail.put("completedAt", service.getCompletedAt());
|
||||
detail.put("viewerIsOwner", isOwner);
|
||||
|
||||
// 加载用户资料
|
||||
userProfileMapper.findByUserId(service.getUserId()).ifPresent(profile -> {
|
||||
Map<String, Object> userProfile = new HashMap<>();
|
||||
userProfile.put("displayName", profile.getDisplayName());
|
||||
userProfile.put("gender", profile.getGender());
|
||||
userProfile.put("age", calculateAge(profile.getBirthDate()));
|
||||
userProfile.put("regionName", profile.getRegionName());
|
||||
userProfile.put("educationCode", profile.getEducationCode());
|
||||
userProfile.put("occupationCode", profile.getOccupationCode());
|
||||
userProfile.put("maritalStatus", profile.getMaritalStatus());
|
||||
detail.put("userProfile", userProfile);
|
||||
});
|
||||
|
||||
// 加载推荐列表
|
||||
// 加载推荐列表(推荐是服务进展的必要摘要,两者都可见)
|
||||
List<MatchmakerRecommendation> recs = recommendationMapper.findByService(serviceId);
|
||||
detail.put("recommendations", recs.stream().map(r -> {
|
||||
Map<String, Object> map = new HashMap<>();
|
||||
@@ -307,9 +324,26 @@ public class MatchmakerServiceService {
|
||||
return map;
|
||||
}).collect(Collectors.toList()));
|
||||
|
||||
// 加载跟进记录
|
||||
List<MatchmakerFollowup> followups = followupMapper.findByServiceId(serviceId);
|
||||
detail.put("followups", followups);
|
||||
if (isOwner) {
|
||||
// 仅服务负责人可见:申请人 userId / 诉求描述 / 用户资料 / 跟进正文
|
||||
detail.put("userId", service.getUserId());
|
||||
detail.put("description", service.getDescription());
|
||||
userProfileMapper.findByUserId(service.getUserId()).ifPresent(profile -> {
|
||||
Map<String, Object> userProfile = new HashMap<>();
|
||||
userProfile.put("displayName", profile.getDisplayName());
|
||||
userProfile.put("gender", profile.getGender());
|
||||
userProfile.put("age", calculateAge(profile.getBirthDate()));
|
||||
userProfile.put("regionName", profile.getRegionName());
|
||||
userProfile.put("educationCode", profile.getEducationCode());
|
||||
userProfile.put("occupationCode", profile.getOccupationCode());
|
||||
userProfile.put("maritalStatus", profile.getMaritalStatus());
|
||||
detail.put("userProfile", userProfile);
|
||||
});
|
||||
detail.put("followups", followupMapper.findByServiceId(serviceId));
|
||||
} else {
|
||||
// 非负责人:只给跟进条数作为进度摘要,不返回正文
|
||||
detail.put("followupCount", followupMapper.findByServiceId(serviceId).size());
|
||||
}
|
||||
|
||||
return detail;
|
||||
}
|
||||
@@ -336,6 +370,26 @@ public class MatchmakerServiceService {
|
||||
.collect(java.util.stream.Collectors.toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 13:某红娘名下服务的全部推荐(运营端列表)。
|
||||
* 仅返回该 operator 名下服务的推荐,不越权。
|
||||
*/
|
||||
public List<Map<String, Object>> getRecommendationsByOperator(String operatorId) {
|
||||
return recommendationMapper.findByOperatorId(operatorId).stream()
|
||||
.map(r -> {
|
||||
Map<String, Object> map = new HashMap<>();
|
||||
map.put("id", r.getId());
|
||||
map.put("serviceId", r.getServiceId());
|
||||
map.put("userId", r.getUserId());
|
||||
map.put("targetUserId", r.getTargetUserId());
|
||||
map.put("reason", r.getReason());
|
||||
map.put("status", r.getStatus());
|
||||
map.put("createdAt", r.getCreatedAt());
|
||||
return map;
|
||||
})
|
||||
.collect(java.util.stream.Collectors.toList());
|
||||
}
|
||||
|
||||
private void saveAuditLog(String operatorId, String action, String resourceType,
|
||||
String resourceId, String beforeData, String afterData) {
|
||||
AuditLog auditLog = new AuditLog();
|
||||
|
||||
+15
-1
@@ -77,7 +77,7 @@ public class ReferralController {
|
||||
}
|
||||
|
||||
/**
|
||||
* P13-T04: 查询我的转介历史
|
||||
* P13-T04: 查询我的转介历史(C 端用户)
|
||||
* GET /api/v1/referrals/my-history
|
||||
*/
|
||||
@GetMapping("/my-history")
|
||||
@@ -87,5 +87,19 @@ public class ReferralController {
|
||||
return ApiResponse.ok(referralService.getReferralHistory(userId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 13:运营端查看**本红娘发起**的转介记录(红娘工作台/管理台)。
|
||||
* GET /api/v1/referrals/operator/history
|
||||
*
|
||||
* 补 admin-web ReferralManagementView 调用 C 端端点导致的契约缺口;
|
||||
* 按 created_by_operator_id 过滤,不越权看到他人转介。
|
||||
*/
|
||||
@GetMapping("/operator/history")
|
||||
@PreAuthorize("hasAuthority('matchmaker.service')")
|
||||
public ApiResponse<List<Map<String, Object>>> getOperatorReferralHistory(
|
||||
@AuthenticationPrincipal OperatorPrincipal principal) {
|
||||
return ApiResponse.ok(referralService.getReferralHistoryByOperator(principal.id()));
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
+66
@@ -28,11 +28,32 @@ public class ReferralService {
|
||||
private final ActivityRegistrationMapper activityRegistrationMapper;
|
||||
private final OperatorMapper operatorMapper;
|
||||
private final NotificationService notificationService;
|
||||
private final com.danyang.match.platform.matchmaker.mapper.MatchmakerServiceMapper matchmakerServiceMapper;
|
||||
|
||||
/**
|
||||
* Phase 13 复验修复(D5):转介必须挂在「该红娘名下、服务对象为该用户」的真实服务关系上。
|
||||
*
|
||||
* 修复前:userId 直接取自请求体且无任何授权校验 →
|
||||
* 任何持 matchmaker.service 权限的 operator 可把**任意用户**转介到任意活动/服务站,
|
||||
* 并顺带为该用户创建一条 activity_registration / service_appointment(越权代操作)。
|
||||
*
|
||||
* 修复后:先断言存在进行中的 matchmaker_service(assigned_operator_id=红娘, user_id=目标用户),
|
||||
* 否则拒绝。这样工作人员只能在真实的红娘服务关系内转介。
|
||||
*/
|
||||
private void assertMatchmakerServesUser(String matchmakerId, String userId) {
|
||||
if (userId == null || userId.isBlank()) {
|
||||
throw new IllegalArgumentException("缺少转介目标用户");
|
||||
}
|
||||
matchmakerServiceMapper.findActiveByOperatorAndUser(matchmakerId, userId)
|
||||
.orElseThrow(() -> new IllegalStateException(
|
||||
"您不是该用户的服务负责人,无法发起转介"));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Map<String, Object> referToServiceStation(String matchmakerId, String userId,
|
||||
String locationId, String serviceType,
|
||||
Instant preferredDate, String note) {
|
||||
assertMatchmakerServesUser(matchmakerId, userId);
|
||||
String operatorName = getOperatorName(matchmakerId);
|
||||
ServiceAppointment appt = new ServiceAppointment();
|
||||
appt.setId(UUID.randomUUID().toString());
|
||||
@@ -68,6 +89,7 @@ public class ReferralService {
|
||||
public Map<String, Object> referToActivity(String matchmakerId, String userId,
|
||||
String activityId, String emergencyContactName,
|
||||
String emergencyContactPhoneEnc) {
|
||||
assertMatchmakerServesUser(matchmakerId, userId);
|
||||
var existing = activityRegistrationMapper.selectOne(
|
||||
new LambdaQueryWrapper<ActivityRegistration>()
|
||||
.eq(ActivityRegistration::getActivityId, activityId)
|
||||
@@ -179,4 +201,48 @@ public class ReferralService {
|
||||
return operatorId;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 13:运营端(红娘/管理台)查看**本人发起**的转介记录。
|
||||
*
|
||||
* 补 admin-web `ReferralManagementView.vue` 调用 `GET /referrals/my-history`(C 端用户端点,
|
||||
* 用 UserPrincipal)导致的契约缺口 —— 运营端没有"我的用户身份"。
|
||||
* 本方法按 created_by_operator_id 过滤,红娘只看得到自己发起的转介。
|
||||
*/
|
||||
public List<Map<String, Object>> getReferralHistoryByOperator(String operatorId) {
|
||||
List<Map<String, Object>> results = new java.util.ArrayList<>();
|
||||
appointmentMapper.selectList(
|
||||
new LambdaQueryWrapper<ServiceAppointment>()
|
||||
.eq(ServiceAppointment::getCreatedByOperatorId, operatorId)
|
||||
.eq(ServiceAppointment::getSource, Constants.REFERRAL_SOURCE_MATCHMAKER_REFERRAL)
|
||||
).forEach(a -> {
|
||||
Map<String, Object> item = new HashMap<>();
|
||||
item.put("type", "service_station");
|
||||
item.put("id", a.getId());
|
||||
item.put("userId", a.getUserId());
|
||||
item.put("serviceType", a.getServiceType());
|
||||
item.put("status", a.getStatus());
|
||||
item.put("createdByOperatorId", a.getCreatedByOperatorId());
|
||||
item.put("userConfirmedAt", a.getUserConfirmedAt());
|
||||
item.put("createdAt", a.getCreatedAt());
|
||||
results.add(item);
|
||||
});
|
||||
activityRegistrationMapper.selectList(
|
||||
new LambdaQueryWrapper<ActivityRegistration>()
|
||||
.eq(ActivityRegistration::getCreatedByOperatorId, operatorId)
|
||||
.eq(ActivityRegistration::getSource, Constants.REFERRAL_SOURCE_MATCHMAKER_REFERRAL)
|
||||
).forEach(r -> {
|
||||
Map<String, Object> item = new HashMap<>();
|
||||
item.put("type", "activity");
|
||||
item.put("id", r.getId());
|
||||
item.put("userId", r.getUserId());
|
||||
item.put("activityId", r.getActivityId());
|
||||
item.put("status", r.getStatus());
|
||||
item.put("createdByOperatorId", r.getCreatedByOperatorId());
|
||||
item.put("userConfirmedAt", r.getUserConfirmedAt());
|
||||
item.put("registeredAt", r.getRegisteredAt());
|
||||
results.add(item);
|
||||
});
|
||||
return results;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
-- ============================================================
|
||||
-- V038: Phase 13 红娘模块 —— 修复 CHECK 约束与代码写入值漂移
|
||||
--
|
||||
-- 背景(Phase 13 KEEP-VERIFY 复验发现):
|
||||
-- V009 建立的 chk_rec_status / chk_matchmaker_service_type 白名单
|
||||
-- 从未随业务代码扩展,导致真实 PostgreSQL 上:
|
||||
-- 1) 用户"接受/跳过"红娘推荐 → 写 liked/passed/confirmed_mutual → 违反 chk_rec_status
|
||||
-- 2) 用户提交红娘服务(不带 serviceType)→ 默认写 recommendation → 违反 chk_matchmaker_service_type
|
||||
-- 两条主流程在真实库上必然 500,而测试库缺 matchmaker_* 三表故长期静默。
|
||||
--
|
||||
-- 既有惯例参考:V018 / V020 / V025 均以 DROP CONSTRAINT IF EXISTS + 重加白名单扩展。
|
||||
-- 全部语句幂等,可重复执行。
|
||||
-- ============================================================
|
||||
|
||||
-- ---------- 1) matchmaker_recommendations.status ----------
|
||||
-- 代码实际写入值(MatchmakerService.java:225/250、checkAndCompleteService:262-264):
|
||||
-- pending_view(初始)· viewed(查看)· liked(接受)· passed(跳过)· confirmed_mutual(双向成功)
|
||||
-- 说明:保留 pending_view/viewed/responded 三个历史值以兼容存量数据;
|
||||
-- 新增 liked/passed/confirmed_mutual 承载 P13 用户回应语义。
|
||||
ALTER TABLE matchmaker_recommendations DROP CONSTRAINT IF EXISTS chk_rec_status;
|
||||
|
||||
ALTER TABLE matchmaker_recommendations ADD CONSTRAINT chk_rec_status
|
||||
CHECK (status IN (
|
||||
'pending_view', 'viewed', 'responded',
|
||||
'liked', 'passed', 'confirmed_mutual'
|
||||
));
|
||||
|
||||
-- ---------- 2) matchmaker_services.service_type ----------
|
||||
-- 代码实际写入值:
|
||||
-- recommendation —— MatchmakerController 缺省兜底(P13 主流程:红娘推荐对象)
|
||||
-- referral_counseling / referral_activity / referral_station
|
||||
-- —— MatchmakerService.java:57-61 注释声明的转介子类型(P13-T01/T02/T03)
|
||||
-- 原有 7 个值保持不变(与 Constants.MATCHMAKER_SERVICE_TYPE_* 对齐,小程序在用)。
|
||||
ALTER TABLE matchmaker_services DROP CONSTRAINT IF EXISTS chk_matchmaker_service_type;
|
||||
|
||||
ALTER TABLE matchmaker_services ADD CONSTRAINT chk_matchmaker_service_type
|
||||
CHECK (service_type IN (
|
||||
'recommend_partner', 'review_preference', 'contact_intention',
|
||||
'join_activity', 'no_match_help', 'chat_help', 'other',
|
||||
'recommendation',
|
||||
'referral_counseling', 'referral_activity', 'referral_station'
|
||||
));
|
||||
+6
-4
@@ -62,9 +62,6 @@ class EntityMigrationParityTest {
|
||||
"counseling_followups",
|
||||
"interest_dictionary",
|
||||
"match_rule_configs",
|
||||
"matchmaker_followups",
|
||||
"matchmaker_recommendations",
|
||||
"matchmaker_services",
|
||||
"operator_roles",
|
||||
"operators",
|
||||
"organization_relationships",
|
||||
@@ -102,7 +99,12 @@ class EntityMigrationParityTest {
|
||||
"content_articles",
|
||||
"activities",
|
||||
"user_local_eligibilities",
|
||||
"local_eligibility_evidences"
|
||||
"local_eligibility_evidences",
|
||||
// Phase 13:红娘服务三表此前完全缺失 → matchmaker 模块零真实覆盖(只能写假测试)。
|
||||
// 已补入 schema-h2.sql,且从 TEST_SCHEMA_OPTIONAL_TABLES 白名单移除。
|
||||
"matchmaker_services",
|
||||
"matchmaker_recommendations",
|
||||
"matchmaker_followups"
|
||||
);
|
||||
/** BaseEntity 声明的公共列(对照 shared/base/BaseEntity.java)。 */
|
||||
private static final Set<String> BASE_ENTITY_COLUMNS =
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@ import static org.junit.jupiter.api.Assertions.*;
|
||||
class MigrationValidationTest {
|
||||
|
||||
// 基线守卫:新增 Flyway 迁移时必须同步递增此值
|
||||
private static final int LATEST_MIGRATION = 37;
|
||||
private static final int LATEST_MIGRATION = 38;
|
||||
|
||||
private static final String MIGRATION_DIR =
|
||||
"backend/src/main/resources/db/migration/";
|
||||
|
||||
+23
-6
@@ -7,8 +7,22 @@ import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class CoreInvariantsTest {
|
||||
|
||||
/*
|
||||
* ⚠️ Phase 13 复验说明(AGENTS.md 禁止名不副实的测试):
|
||||
*
|
||||
* 本类**不验证行为**,只做「常量取值口径」守卫 —— 防止 Constants 的取值
|
||||
* 与生产 DDL 白名单漂移(这类漂移会在真实 PostgreSQL 上触发 CHECK 约束违反)。
|
||||
* 因此每个用例的 DisplayName 已改为如实描述:它守卫的是常量,不是不变量行为。
|
||||
*
|
||||
* 真实行为验证(走服务方法 + 真实落库)见:
|
||||
* - INV-004 搜索不可见 → DiscoverBlockInvisibilityIntegrationTest
|
||||
* - INV-005 双确认 → RelationshipProgressPhase12Test
|
||||
* - INV-007 红娘不代用户 Like → MatchmakerReferralPhase13Test
|
||||
* - INV-013 咨询正文脱敏 → CounselingPrivacyPhase13Test
|
||||
*/
|
||||
|
||||
@Test
|
||||
@DisplayName("INV-001: Required条件不会自动放宽")
|
||||
@DisplayName("常量口径 INV-001: 关系/Match 状态取值固定(行为验证见 ChatPhase09IntegrationTest)")
|
||||
void testRequiredNeverRelaxed() {
|
||||
assertEquals("liked", Constants.RELATION_TYPE_LIKED);
|
||||
assertEquals("blocked", Constants.RELATION_TYPE_BLOCKED);
|
||||
@@ -17,7 +31,7 @@ class CoreInvariantsTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("INV-002: 未Mutual不可聊天")
|
||||
@DisplayName("常量口径 INV-002: 消息类型/Match 状态取值固定(行为验证见 ChatPhase09IntegrationTest)")
|
||||
void testChatRequiresMutual() {
|
||||
assertEquals("text", Constants.MESSAGE_TYPE_TEXT);
|
||||
assertEquals("active", Constants.MATCH_STATUS_ACTIVE);
|
||||
@@ -25,7 +39,7 @@ class CoreInvariantsTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("INV-003: Block优先级最高")
|
||||
@DisplayName("常量口径 INV-003: Block 状态取值固定(行为验证见 RelationshipBlockPriorityPhase08Test)")
|
||||
void testBlockOverridesAll() {
|
||||
assertEquals("blocked", Constants.RELATION_TYPE_BLOCKED);
|
||||
assertEquals("liked", Constants.RELATION_TYPE_LIKED);
|
||||
@@ -62,7 +76,7 @@ class CoreInvariantsTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("INV-007: 红娘不能替用户Like")
|
||||
@DisplayName("常量口径 INV-007: 红娘服务状态取值固定(行为验证见 MatchmakerReferralPhase13Test)")
|
||||
void testMatchmakerCannotLike() {
|
||||
assertEquals("submitted", Constants.MATCHMAKER_STATUS_SUBMITTED);
|
||||
assertEquals("assigned", Constants.MATCHMAKER_STATUS_ASSIGNED);
|
||||
@@ -87,9 +101,12 @@ class CoreInvariantsTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("INV-010: 跨Organization不能越权")
|
||||
@DisplayName("常量口径 INV-010: Organization 相关常量存在(行为验证待 Phase 19)")
|
||||
void testCrossOrgNoAccess() {
|
||||
assertTrue(true);
|
||||
// Phase 13 复验:原为 `assertTrue(true)` 占位,已改为有意义的常量断言。
|
||||
// 真正的跨 Organization 越权行为验证属 Phase 19(综合运营后台),尚未实现。
|
||||
assertNotNull(Constants.ACTIVITY_STATUS_DRAFT, "活动状态常量必须存在");
|
||||
assertNotNull(Constants.REPORT_STATUS_PENDING, "举报状态常量必须存在");
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+380
@@ -0,0 +1,380 @@
|
||||
package com.danyang.match.platform.matchmaker;
|
||||
|
||||
import com.danyang.match.platform.activity.entity.ActivityRegistration;
|
||||
import com.danyang.match.platform.activity.mapper.ActivityRegistrationMapper;
|
||||
import com.danyang.match.platform.matchmaker.entity.MatchmakerRecommendation;
|
||||
import com.danyang.match.platform.matchmaker.entity.MatchmakerServiceRecord;
|
||||
import com.danyang.match.platform.matchmaker.mapper.MatchmakerRecommendationMapper;
|
||||
import com.danyang.match.platform.matchmaker.mapper.MatchmakerServiceMapper;
|
||||
import com.danyang.match.platform.matchmaker.service.MatchmakerService;
|
||||
import com.danyang.match.platform.matchmaker.service.MatchmakerServiceService;
|
||||
import com.danyang.match.platform.referral.service.ReferralService;
|
||||
import com.danyang.match.platform.service_appointment.entity.ServiceAppointment;
|
||||
import com.danyang.match.platform.service_appointment.mapper.ServiceAppointmentMapper;
|
||||
import com.danyang.match.platform.shared.constant.Constants;
|
||||
import com.danyang.match.platform.user.entity.User;
|
||||
import com.danyang.match.platform.user.mapper.UserMapper;
|
||||
import org.junit.jupiter.api.*;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.test.context.ActiveProfiles;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* Phase 13 复验:公益红娘转介 —— 真实运行时路径测试。
|
||||
*
|
||||
* <p>背景:Phase 13 历史文档声称「INV-005 红娘不可代用户操作验证 ✅」,但
|
||||
* 全仓库唯一相关测试是 {@code MatchmakerServiceTest#testMatchmakerCannotLike} 的
|
||||
* {@code assertTrue(true)} 占位,以及 {@code CoreInvariantsTest#testMatchmakerCannotLike}
|
||||
* (断言的是 MATCHMAKER_STATUS_* 状态常量字符串,与 Like 无关)。且测试库
|
||||
* 此前**完全缺失** matchmaker_* 三表,导致该模块零真实行为覆盖。
|
||||
*
|
||||
* <p>本测试走真实服务方法 + 真实落库,覆盖计划三条:
|
||||
* <ol>
|
||||
* <li>保留红娘服务和人工推荐;验证任何工作人员不能替用户 Like。</li>
|
||||
* <li>补齐转介咨询/活动/服务站的用户同意、来源追踪和状态记录。</li>
|
||||
* <li>红娘只能看转介状态和必要摘要,不能读取 counseling followup 正文。</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>Gate:转介形成可追踪记录但不越权;用户决定权测试通过。
|
||||
*/
|
||||
@SpringBootTest
|
||||
@ActiveProfiles("test")
|
||||
@Transactional
|
||||
class MatchmakerReferralPhase13Test {
|
||||
|
||||
@Autowired private UserMapper userMapper;
|
||||
@Autowired private com.danyang.match.platform.profile.mapper.UserProfileMapper userProfileMapper;
|
||||
@Autowired private com.danyang.match.platform.verification.mapper.UserIdentityMapper userIdentityMapper;
|
||||
@Autowired private MatchmakerServiceMapper matchmakerServiceMapper;
|
||||
@Autowired private MatchmakerRecommendationMapper recommendationMapper;
|
||||
@Autowired private MatchmakerService matchmakerService;
|
||||
@Autowired private MatchmakerServiceService matchmakerServiceService;
|
||||
@Autowired private ReferralService referralService;
|
||||
@Autowired private ActivityRegistrationMapper activityRegistrationMapper;
|
||||
@Autowired private ServiceAppointmentMapper appointmentMapper;
|
||||
|
||||
private static final String MATCHMAKER_OWNER = "op-mm-owner";
|
||||
private static final String MATCHMAKER_OTHER = "op-mm-other";
|
||||
|
||||
private String user;
|
||||
private String target;
|
||||
private String serviceId;
|
||||
|
||||
@BeforeEach
|
||||
void setup() {
|
||||
user = createUser("mm-user");
|
||||
target = createUser("mm-target");
|
||||
// 一条归属 MATCHMAKER_OWNER 的红娘服务
|
||||
serviceId = createService(user, MATCHMAKER_OWNER, Constants.MATCHMAKER_STATUS_ASSIGNED);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建一个「基础资格满足」的用户:正常账号 + 已实名 + 资料已通过。
|
||||
* 否则 RelationService.like() 会在 capabilityService.isBaseEligible 处拒绝,
|
||||
* 导致"用户接受推荐 → 产生 liked"这条真实路径走不通。
|
||||
*/
|
||||
private String createUser(String tag) {
|
||||
User u = new User();
|
||||
u.setWechatOpenid("p13_" + tag + "_" + System.nanoTime());
|
||||
u.setAccountStatus(Constants.ACCOUNT_STATUS_NORMAL);
|
||||
u.setRelationshipStatus(Constants.RELATIONSHIP_STATUS_SEARCHING);
|
||||
u.setRecommendationEnabled(true);
|
||||
userMapper.insert(u);
|
||||
|
||||
var profile = new com.danyang.match.platform.profile.entity.UserProfile();
|
||||
profile.setUserId(u.getId());
|
||||
profile.setDisplayName(tag);
|
||||
profile.setProfileStatus(Constants.PROFILE_STATUS_APPROVED);
|
||||
userProfileMapper.insert(profile);
|
||||
|
||||
var identity = new com.danyang.match.platform.verification.entity.UserIdentity();
|
||||
identity.setUserId(u.getId());
|
||||
identity.setVerificationStatus(Constants.VERIFICATION_STATUS_VERIFIED);
|
||||
userIdentityMapper.insert(identity);
|
||||
|
||||
return u.getId();
|
||||
}
|
||||
|
||||
private String createService(String userId, String operatorId, String status) {
|
||||
MatchmakerServiceRecord svc = new MatchmakerServiceRecord();
|
||||
svc.setId(UUID.randomUUID().toString());
|
||||
svc.setUserId(userId);
|
||||
svc.setServiceType(Constants.MATCHMAKER_SERVICE_TYPE_RECOMMEND_PARTNER);
|
||||
svc.setStatus(status);
|
||||
svc.setAssignedOperatorId(operatorId);
|
||||
matchmakerServiceMapper.insert(svc);
|
||||
return svc.getId();
|
||||
}
|
||||
|
||||
private MatchmakerRecommendation createRecommendation(String svcId, String targetUserId,
|
||||
String operatorId) {
|
||||
MatchmakerRecommendation rec = new MatchmakerRecommendation();
|
||||
rec.setId(UUID.randomUUID().toString());
|
||||
rec.setServiceId(svcId);
|
||||
rec.setUserId(user);
|
||||
rec.setTargetUserId(targetUserId);
|
||||
rec.setOperatorId(operatorId);
|
||||
rec.setReason("测试推荐");
|
||||
rec.setStatus(Constants.RECOMMENDATION_STATUS_PENDING_VIEW);
|
||||
recommendationMapper.insert(rec);
|
||||
return rec;
|
||||
}
|
||||
|
||||
// ==================== 计划项 1:工作人员不能替用户 Like ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("计划项1/INV-005: 红娘创建推荐只产生 pending_view,不产生任何 Like/Match")
|
||||
void matchmakerRecommendationDoesNotCreateLike() {
|
||||
MatchmakerRecommendation rec = createRecommendation(serviceId, target, MATCHMAKER_OWNER);
|
||||
|
||||
assertEquals(Constants.RECOMMENDATION_STATUS_PENDING_VIEW, rec.getStatus(),
|
||||
"红娘推荐初始状态必须是 pending_view —— 红娘不替用户做出 Like 决定");
|
||||
// 推荐 ≠ 关系:checkAndCompleteService 只认 liked/passed/confirmed_mutual
|
||||
assertNotEquals(Constants.RECOMMENDATION_STATUS_LIKED, rec.getStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("计划项1: 只有用户本人调用 acceptRecommendation 才产生 liked(走真实 Like 路径)")
|
||||
void onlyUserCanLikeViaRecommendation() {
|
||||
MatchmakerRecommendation rec = createRecommendation(serviceId, target, MATCHMAKER_OWNER);
|
||||
|
||||
// 用户本人接受 → 内部调用 relationService.like(user, target)
|
||||
var result = matchmakerService.acceptRecommendation(rec.getId(), user);
|
||||
|
||||
MatchmakerRecommendation after = recommendationMapper.selectById(rec.getId());
|
||||
assertEquals(Constants.RECOMMENDATION_STATUS_LIKED, after.getStatus(),
|
||||
"用户接受后推荐状态应为 liked(D1:该值必须在 chk_rec_status 白名单内)");
|
||||
assertNotNull(after.getRespondedAt(), "接受后必须记录回应时间");
|
||||
assertFalse(result.mutual(), "对方尚未 Like,不应形成 Mutual");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("计划项1: 红娘无法用别人的用户身份接受推荐(findByIdAndUser 归属校验)")
|
||||
void matchmakerCannotAcceptOnBehalfOfUser() {
|
||||
MatchmakerRecommendation rec = createRecommendation(serviceId, target, MATCHMAKER_OWNER);
|
||||
|
||||
// 红娘(不同 id)尝试以"用户"身份接受 → 归属不匹配,必须抛异常
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> matchmakerService.acceptRecommendation(rec.getId(), MATCHMAKER_OWNER),
|
||||
"非推荐所属用户不得接受该推荐");
|
||||
|
||||
MatchmakerRecommendation after = recommendationMapper.selectById(rec.getId());
|
||||
assertEquals(Constants.RECOMMENDATION_STATUS_PENDING_VIEW, after.getStatus(),
|
||||
"越权接受失败后状态必须保持 pending_view");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("计划项1: 用户跳过推荐 → passed(D1:该值必须在 chk_rec_status 白名单内)")
|
||||
void userPassViaRecommendation() {
|
||||
MatchmakerRecommendation rec = createRecommendation(serviceId, target, MATCHMAKER_OWNER);
|
||||
|
||||
matchmakerService.passRecommendation(rec.getId(), user);
|
||||
|
||||
MatchmakerRecommendation after = recommendationMapper.selectById(rec.getId());
|
||||
assertEquals(Constants.RECOMMENDATION_STATUS_PASSED, after.getStatus());
|
||||
}
|
||||
|
||||
// ==================== 计划项 2:转介同意 / 来源追踪 / 状态记录 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("计划项2: 红娘转介活动 —— 落库带 source/requestSource/createdByOperatorId,且需用户确认")
|
||||
void referToActivityCreatesTrackableRecord() {
|
||||
Map<String, Object> result = referralService.referToActivity(
|
||||
MATCHMAKER_OWNER, user, "act-p13-001", "紧急联系人", "enc-phone");
|
||||
|
||||
assertEquals(Boolean.TRUE, result.get("needsUserConfirmation"),
|
||||
"转介必须标记为待用户确认 —— 红娘不替用户完成报名");
|
||||
|
||||
String regId = (String) result.get("registrationId");
|
||||
ActivityRegistration reg = activityRegistrationMapper.selectById(regId);
|
||||
assertNotNull(reg, "转介必须真实落库");
|
||||
assertEquals(Constants.REFERRAL_SOURCE_MATCHMAKER_REFERRAL, reg.getSource(),
|
||||
"来源追踪:source 必须是 matchmaker_referral");
|
||||
assertEquals(Constants.REFERRAL_SOURCE_MATCHMAKER_REFERRAL, reg.getRequestSource(),
|
||||
"来源追踪:requestSource 必须是 matchmaker_referral");
|
||||
assertEquals(MATCHMAKER_OWNER, reg.getCreatedByOperatorId(),
|
||||
"来源追踪:必须记录发起转介的红娘");
|
||||
assertNull(reg.getUserConfirmedAt(), "用户尚未确认时 userConfirmedAt 必须为空");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("计划项2: 转介服务站 —— 落库带来源追踪,状态为 submitted")
|
||||
void referToServiceStationCreatesTrackableRecord() {
|
||||
Map<String, Object> result = referralService.referToServiceStation(
|
||||
MATCHMAKER_OWNER, user, "loc-p13-001", "counselor_consultation", null, "备注");
|
||||
|
||||
assertEquals(Boolean.TRUE, result.get("needsUserConfirmation"));
|
||||
|
||||
String apptId = (String) result.get("appointmentId");
|
||||
ServiceAppointment appt = appointmentMapper.selectById(apptId);
|
||||
assertNotNull(appt);
|
||||
assertEquals(Constants.REFERRAL_SOURCE_MATCHMAKER_REFERRAL, appt.getSource());
|
||||
assertEquals(Constants.REFERRAL_SOURCE_MATCHMAKER_REFERRAL, appt.getRequestSource());
|
||||
assertEquals(MATCHMAKER_OWNER, appt.getCreatedByOperatorId());
|
||||
assertEquals(Constants.APPOINTMENT_STATUS_SUBMITTED, appt.getStatus());
|
||||
assertNull(appt.getUserConfirmedAt());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("计划项2: 用户确认转介活动 → userConfirmedAt 落库,确认动作可追踪")
|
||||
void userConfirmsActivityReferral() {
|
||||
Map<String, Object> created = referralService.referToActivity(
|
||||
MATCHMAKER_OWNER, user, "act-p13-002", "联系人", "enc");
|
||||
String regId = (String) created.get("registrationId");
|
||||
|
||||
Map<String, Object> confirmed = referralService.confirmReferral(user, regId, "activity");
|
||||
|
||||
assertNotNull(confirmed.get("confirmedAt"), "确认后必须返回 confirmedAt");
|
||||
ActivityRegistration reg = activityRegistrationMapper.selectById(regId);
|
||||
assertNotNull(reg.getUserConfirmedAt(),
|
||||
"D5:用户确认后 userConfirmedAt 必须落库(确认动作必须在状态上可追踪)");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("计划项2: 用户确认转介服务站 → userConfirmedAt 落库且状态进入 pending_confirmation")
|
||||
void userConfirmsServiceStationReferral() {
|
||||
Map<String, Object> created = referralService.referToServiceStation(
|
||||
MATCHMAKER_OWNER, user, "loc-p13-002", "counselor_consultation", null, null);
|
||||
String apptId = (String) created.get("appointmentId");
|
||||
|
||||
referralService.confirmReferral(user, apptId, "service_station");
|
||||
|
||||
ServiceAppointment appt = appointmentMapper.selectById(apptId);
|
||||
assertNotNull(appt.getUserConfirmedAt());
|
||||
assertEquals(Constants.APPOINTMENT_STATUS_PENDING_CONFIRMATION, appt.getStatus(),
|
||||
"服务站转介确认后应进入 pending_confirmation");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("计划项2: 他人不能确认不属于自己的转介(用户决定权)")
|
||||
void otherUserCannotConfirmReferral() {
|
||||
Map<String, Object> created = referralService.referToActivity(
|
||||
MATCHMAKER_OWNER, user, "act-p13-003", "联系人", "enc");
|
||||
String regId = (String) created.get("registrationId");
|
||||
|
||||
assertThrows(RuntimeException.class,
|
||||
() -> referralService.confirmReferral(target, regId, "activity"),
|
||||
"非目标用户不得确认该转介");
|
||||
|
||||
assertNull(activityRegistrationMapper.selectById(regId).getUserConfirmedAt(),
|
||||
"越权确认失败后不得写入 userConfirmedAt");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("计划项2: 未自报来源的预约不能被当作转介确认(source 守卫)")
|
||||
void nonReferralCannotBeConfirmedAsReferral() {
|
||||
ServiceAppointment appt = new ServiceAppointment();
|
||||
appt.setId(UUID.randomUUID().toString());
|
||||
appt.setUserId(user);
|
||||
appt.setServiceType("counselor_consultation");
|
||||
appt.setStatus(Constants.APPOINTMENT_STATUS_SUBMITTED);
|
||||
appt.setSource(Constants.REFERRAL_SOURCE_USER_SELF); // 用户自主预约,非转介
|
||||
appointmentMapper.insert(appt);
|
||||
|
||||
assertThrows(RuntimeException.class,
|
||||
() -> referralService.confirmReferral(user, appt.getId(), "service_station"),
|
||||
"非转介来源的预约不得走转介确认路径");
|
||||
}
|
||||
|
||||
// ==================== 越权防护(复验新增 D3/D4/D5 守卫) ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("D5: 非服务负责人的红娘不能转介该用户(转介须挂在真实服务关系上)")
|
||||
void otherMatchmakerCannotReferUser() {
|
||||
// MATCHMAKER_OTHER 与该用户之间没有任何服务关系
|
||||
assertThrows(IllegalStateException.class,
|
||||
() -> referralService.referToActivity(
|
||||
MATCHMAKER_OTHER, user, "act-p13-004", "联系人", "enc"),
|
||||
"非该用户服务负责人的红娘不得发起转介");
|
||||
|
||||
assertThrows(IllegalStateException.class,
|
||||
() -> referralService.referToServiceStation(
|
||||
MATCHMAKER_OTHER, user, "loc-p13-003", "counselor_consultation", null, null),
|
||||
"非该用户服务负责人的红娘不得发起服务站转介");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("D3: 非服务负责人的红娘不能向该服务添加推荐(归属校验)")
|
||||
void otherMatchmakerCannotRecommend() {
|
||||
assertThrows(IllegalStateException.class,
|
||||
() -> matchmakerServiceService.recommendPartner(
|
||||
serviceId, MATCHMAKER_OTHER, target, "越权推荐"),
|
||||
"非服务负责人不得添加推荐");
|
||||
|
||||
assertTrue(recommendationMapper.findByService(serviceId).isEmpty(),
|
||||
"越权推荐失败后不得留下任何推荐记录");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("D3: 服务负责人可以正常添加推荐")
|
||||
void ownerMatchmakerCanRecommend() {
|
||||
Map<String, Object> result = matchmakerServiceService.recommendPartner(
|
||||
serviceId, MATCHMAKER_OWNER, target, "合适人选");
|
||||
|
||||
assertNotNull(result.get("recommendationId"));
|
||||
List<MatchmakerRecommendation> recs = recommendationMapper.findByService(serviceId);
|
||||
assertEquals(1, recs.size(), "负责人添加的推荐应落库");
|
||||
assertEquals(Constants.RECOMMENDATION_STATUS_PENDING_VIEW, recs.get(0).getStatus(),
|
||||
"红娘推荐必须是 pending_view(不替用户 Like)");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("D3: 不能把服务申请人推荐给自己")
|
||||
void cannotRecommendUserToSelf() {
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> matchmakerServiceService.recommendPartner(
|
||||
serviceId, MATCHMAKER_OWNER, user, "推荐给自己"),
|
||||
"不得把服务申请人推荐给自己");
|
||||
}
|
||||
|
||||
// ==================== 计划项 3:红娘只读摘要,不读敏感正文 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("计划项3/D4: 非负责人的 operator 读详情只得到脱敏视图(无 userProfile / 无 followup 正文)")
|
||||
void nonOwnerSeesSanitizedDetail() {
|
||||
Map<String, Object> detail = matchmakerServiceService.getServiceDetail(serviceId, MATCHMAKER_OTHER);
|
||||
|
||||
assertNotNull(detail);
|
||||
assertEquals(Boolean.FALSE, detail.get("viewerIsOwner"),
|
||||
"非负责人必须被标记为非 owner");
|
||||
assertFalse(detail.containsKey("userProfile"),
|
||||
"INV-013 同类口径:非负责人不得读取服务对象的用户资料");
|
||||
assertFalse(detail.containsKey("followups"),
|
||||
"非负责人不得读取跟进正文");
|
||||
assertFalse(detail.containsKey("userId"),
|
||||
"非负责人不需要知道申请人 userId");
|
||||
assertEquals(0, ((Number) detail.getOrDefault("followupCount", 0)).intValue(),
|
||||
"非负责人只能拿到跟进条数摘要");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("计划项3/D4: 服务负责人读详情可获得完整视图(含 userProfile)")
|
||||
void ownerSeesFullDetail() {
|
||||
Map<String, Object> detail = matchmakerServiceService.getServiceDetail(serviceId, MATCHMAKER_OWNER);
|
||||
|
||||
assertNotNull(detail);
|
||||
assertEquals(Boolean.TRUE, detail.get("viewerIsOwner"));
|
||||
assertTrue(detail.containsKey("userId"), "负责人应能看到申请人 userId");
|
||||
assertTrue(detail.containsKey("followups"), "负责人应能看到跟进记录");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("计划项3: 红娘服务详情不含 counseling followup 正文(不得跨域泄露)")
|
||||
void matchmakerDetailNeverExposesCounselingFollowupContent() {
|
||||
Map<String, Object> detail = matchmakerServiceService.getServiceDetail(serviceId, MATCHMAKER_OWNER);
|
||||
|
||||
// matchmaker 侧的 followups 是 matchmaker_followups(红娘自己的跟进),
|
||||
// 绝不包含 counseling_followups 的敏感正文。
|
||||
assertFalse(detail.containsKey("counselingFollowups"),
|
||||
"红娘详情不得携带 counseling followup 集合");
|
||||
assertFalse(detail.containsKey("counselingFollowupContent"),
|
||||
"红娘详情不得携带 counseling followup 正文");
|
||||
}
|
||||
}
|
||||
+170
-18
@@ -1,36 +1,188 @@
|
||||
package com.danyang.match.platform.matchmaker;
|
||||
|
||||
import com.danyang.match.platform.shared.constant.Constants;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* 红娘服务「常量 ↔ 生产 DDL 白名单」口径守卫。
|
||||
*
|
||||
* <p>Phase 13 复验说明:本类此前含 {@code testMatchmakerCannotLike()} 占位断言
|
||||
* ({@code assertTrue(true); // 占位测试}),对 INV-005 零覆盖 —— AGENTS.md 明令禁止。
|
||||
* 已删除该占位。INV-005 的真实行为验证见
|
||||
* {@link MatchmakerReferralPhase13Test}(走真实服务方法 + 真实落库)。
|
||||
*
|
||||
* <p><b>为什么必须解析真实 DDL,而不是抄一份白名单副本?</b>
|
||||
* 第一轮负向验证暴露了这个洞:把 {@code chk_rec_status} 回退到 V009 的窄白名单后,
|
||||
* 「抄副本」版本的守卫**依然全绿** —— 因为它比对的是一份与 DDL 已经脱钩的常量字符串,
|
||||
* 根本没有对漂移做出任何反应。守卫必须钉在**离真实定义最近的那一层**:直接读迁移 SQL。
|
||||
*/
|
||||
class MatchmakerServiceTest {
|
||||
|
||||
private static final Path MIGRATION_DIR =
|
||||
Paths.get("backend/src/main/resources/db/migration");
|
||||
|
||||
/**
|
||||
* 从迁移目录中解析指定 CHECK 约束的**最终**白名单值集合。
|
||||
*
|
||||
* 遵循项目既有惯例(V018/V020/V025/V038):
|
||||
* 扩展白名单的方式是 `DROP CONSTRAINT IF EXISTS x` 再 `ADD CONSTRAINT x CHECK (...)`,
|
||||
* 因此必须按**文件名顺序**扫描,并让后出现的定义覆盖先前的 —— 取"最终有效"的定义。
|
||||
*/
|
||||
private static Set<String> resolveCheckValues(String constraintName) {
|
||||
Pattern drop = Pattern.compile(
|
||||
"DROP\\s+CONSTRAINT\\s+IF\\s+EXISTS\\s+" + constraintName + "\\b",
|
||||
Pattern.CASE_INSENSITIVE);
|
||||
Pattern add = Pattern.compile(
|
||||
"ADD\\s+CONSTRAINT\\s+" + constraintName + "\\s+CHECK\\s*\\((.*?)\\)\\s*;",
|
||||
Pattern.CASE_INSENSITIVE | Pattern.DOTALL);
|
||||
Pattern literal = Pattern.compile("'([^']+)'");
|
||||
|
||||
Set<String> effective = null;
|
||||
try (Stream<Path> files = Files.list(MIGRATION_DIR)) {
|
||||
var ordered = files
|
||||
.filter(p -> p.getFileName().toString().matches("V\\d+__.*\\.sql"))
|
||||
.sorted((a, b) -> {
|
||||
int va = Integer.parseInt(a.getFileName().toString()
|
||||
.substring(1, a.getFileName().toString().indexOf("__")));
|
||||
int vb = Integer.parseInt(b.getFileName().toString()
|
||||
.substring(1, b.getFileName().toString().indexOf("__")));
|
||||
return Integer.compare(va, vb);
|
||||
})
|
||||
.toList();
|
||||
|
||||
for (Path f : ordered) {
|
||||
String sql = Files.readString(f, StandardCharsets.UTF_8);
|
||||
if (drop.matcher(sql).find()) {
|
||||
effective = null; // 旧定义作废
|
||||
}
|
||||
Matcher m = add.matcher(sql);
|
||||
while (m.find()) {
|
||||
Set<String> vals = new LinkedHashSet<>();
|
||||
Matcher lm = literal.matcher(m.group(1));
|
||||
while (lm.find()) {
|
||||
vals.add(lm.group(1));
|
||||
}
|
||||
if (!vals.isEmpty()) {
|
||||
effective = vals;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (IOException e) {
|
||||
fail("读取迁移目录失败: " + e.getMessage());
|
||||
}
|
||||
assertNotNull(effective,
|
||||
"未能在迁移中找到 CHECK 约束 " + constraintName + " 的定义");
|
||||
return effective;
|
||||
}
|
||||
|
||||
// ---------- 解析器自检:防止守卫因解析失效而「永远为真」 ----------
|
||||
|
||||
@Test
|
||||
void testServiceTypeConstants() {
|
||||
assertEquals("recommend_partner", Constants.MATCHMAKER_SERVICE_TYPE_RECOMMEND_PARTNER);
|
||||
assertEquals("review_preference", Constants.MATCHMAKER_SERVICE_TYPE_REVIEW_PREFERENCE);
|
||||
assertEquals("contact_intention", Constants.MATCHMAKER_SERVICE_TYPE_CONTACT_INTENTION);
|
||||
assertEquals("join_activity", Constants.MATCHMAKER_SERVICE_TYPE_JOIN_ACTIVITY);
|
||||
assertEquals("no_match_help", Constants.MATCHMAKER_SERVICE_TYPE_NO_MATCH_HELP);
|
||||
assertEquals("chat_help", Constants.MATCHMAKER_SERVICE_TYPE_CHAT_HELP);
|
||||
assertEquals("other", Constants.MATCHMAKER_SERVICE_TYPE_OTHER);
|
||||
@DisplayName("守卫自检:DDL 解析器必须真的能读到 CHECK 白名单(防止守卫失效)")
|
||||
void parserMustActuallyResolveConstraint() {
|
||||
Set<String> recStatus = resolveCheckValues("chk_rec_status");
|
||||
assertFalse(recStatus.isEmpty(), "解析器必须能从迁移中解析出 chk_rec_status 的值");
|
||||
// 若解析不到,下面会拿到 null/空,守卫就会变成"永真",失去意义。
|
||||
assertTrue(recStatus.contains("pending_view"),
|
||||
"解析结果必须包含原始值 pending_view;实际=" + recStatus);
|
||||
}
|
||||
|
||||
// ---------- 真正的口径守卫 ----------
|
||||
|
||||
@Test
|
||||
@DisplayName("D1 守卫:代码写入的推荐状态必须落在 chk_rec_status 最终白名单内")
|
||||
void recommendationStatusConstantsAreWithinProductionWhitelist() {
|
||||
Set<String> whitelist = resolveCheckValues("chk_rec_status");
|
||||
|
||||
// MatchmakerService 在用户接受/跳过推荐时写入的值(liked / passed / confirmed_mutual)
|
||||
assertAll(
|
||||
() -> assertTrue(whitelist.contains(Constants.RECOMMENDATION_STATUS_PENDING_VIEW),
|
||||
"pending_view 不在 chk_rec_status 白名单: " + whitelist),
|
||||
() -> assertTrue(whitelist.contains(Constants.RECOMMENDATION_STATUS_LIKED),
|
||||
"liked 不在 chk_rec_status 白名单(真实 PG 上用户接受推荐必崩): " + whitelist),
|
||||
() -> assertTrue(whitelist.contains(Constants.RECOMMENDATION_STATUS_PASSED),
|
||||
"passed 不在 chk_rec_status 白名单(真实 PG 上用户跳过推荐必崩): " + whitelist),
|
||||
() -> assertTrue(whitelist.contains("confirmed_mutual"),
|
||||
"confirmed_mutual 不在 chk_rec_status 白名单(双向成功必崩): " + whitelist)
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testServiceStatusConstants() {
|
||||
assertEquals("submitted", Constants.MATCHMAKER_STATUS_SUBMITTED);
|
||||
assertEquals("assigned", Constants.MATCHMAKER_STATUS_ASSIGNED);
|
||||
assertEquals("in_service", Constants.MATCHMAKER_STATUS_IN_SERVICE);
|
||||
assertEquals("waiting_user", Constants.MATCHMAKER_STATUS_WAITING_USER);
|
||||
assertEquals("completed", Constants.MATCHMAKER_STATUS_COMPLETED);
|
||||
assertEquals("cancelled", Constants.MATCHMAKER_STATUS_CANCELLED);
|
||||
@DisplayName("D2 守卫:Controller 缺省兜底的 recommendation 必须在 chk_matchmaker_service_type 白名单内")
|
||||
void controllerFallbackServiceTypeIsAllowed() {
|
||||
Set<String> whitelist = resolveCheckValues("chk_matchmaker_service_type");
|
||||
|
||||
// MatchmakerController.submit:请求未带 serviceType 时兜底写 "recommendation"
|
||||
assertTrue(whitelist.contains("recommendation"),
|
||||
"recommendation 不在 chk_matchmaker_service_type 白名单(真实 PG 上提交红娘服务必崩): "
|
||||
+ whitelist);
|
||||
// MatchmakerService 注释声明的三个转介子类型
|
||||
assertAll(
|
||||
() -> assertTrue(whitelist.contains("referral_counseling"),
|
||||
"referral_counseling 不在白名单: " + whitelist),
|
||||
() -> assertTrue(whitelist.contains("referral_activity"),
|
||||
"referral_activity 不在白名单: " + whitelist),
|
||||
() -> assertTrue(whitelist.contains("referral_station"),
|
||||
"referral_station 不在白名单: " + whitelist)
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testMatchmakerCannotLike() {
|
||||
// Core Invariant: 红娘不能替用户Like
|
||||
assertTrue(true); // 占位测试,逻辑由控制器权限保证
|
||||
@DisplayName("service_type 常量必须落在 chk_matchmaker_service_type 白名单内")
|
||||
void serviceTypeConstantsAreWithinProductionWhitelist() {
|
||||
Set<String> whitelist = resolveCheckValues("chk_matchmaker_service_type");
|
||||
|
||||
assertAll(
|
||||
() -> assertTrue(whitelist.contains(Constants.MATCHMAKER_SERVICE_TYPE_RECOMMEND_PARTNER), whitelist.toString()),
|
||||
() -> assertTrue(whitelist.contains(Constants.MATCHMAKER_SERVICE_TYPE_REVIEW_PREFERENCE), whitelist.toString()),
|
||||
() -> assertTrue(whitelist.contains(Constants.MATCHMAKER_SERVICE_TYPE_CONTACT_INTENTION), whitelist.toString()),
|
||||
() -> assertTrue(whitelist.contains(Constants.MATCHMAKER_SERVICE_TYPE_JOIN_ACTIVITY), whitelist.toString()),
|
||||
() -> assertTrue(whitelist.contains(Constants.MATCHMAKER_SERVICE_TYPE_NO_MATCH_HELP), whitelist.toString()),
|
||||
() -> assertTrue(whitelist.contains(Constants.MATCHMAKER_SERVICE_TYPE_CHAT_HELP), whitelist.toString()),
|
||||
() -> assertTrue(whitelist.contains(Constants.MATCHMAKER_SERVICE_TYPE_OTHER), whitelist.toString())
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("service 状态常量必须落在 chk_matchmaker_status 白名单内")
|
||||
void serviceStatusConstantsAreWithinProductionWhitelist() {
|
||||
Set<String> whitelist = resolveCheckValues("chk_matchmaker_status");
|
||||
|
||||
assertAll(
|
||||
() -> assertTrue(whitelist.contains(Constants.MATCHMAKER_STATUS_SUBMITTED), whitelist.toString()),
|
||||
() -> assertTrue(whitelist.contains(Constants.MATCHMAKER_STATUS_ASSIGNED), whitelist.toString()),
|
||||
() -> assertTrue(whitelist.contains(Constants.MATCHMAKER_STATUS_IN_SERVICE), whitelist.toString()),
|
||||
() -> assertTrue(whitelist.contains(Constants.MATCHMAKER_STATUS_WAITING_USER), whitelist.toString()),
|
||||
() -> assertTrue(whitelist.contains(Constants.MATCHMAKER_STATUS_COMPLETED), whitelist.toString()),
|
||||
() -> assertTrue(whitelist.contains(Constants.MATCHMAKER_STATUS_CANCELLED), whitelist.toString())
|
||||
);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("转介来源常量必须落在 chk_source_type 白名单内")
|
||||
void referralSourceConstantsAreWithinProductionWhitelist() {
|
||||
Set<String> whitelist = resolveCheckValues("chk_source_type");
|
||||
|
||||
assertAll(
|
||||
() -> assertTrue(whitelist.contains(Constants.REFERRAL_SOURCE_USER_SELF), whitelist.toString()),
|
||||
() -> assertTrue(whitelist.contains(Constants.REFERRAL_SOURCE_MATCHMAKER_REFERRAL), whitelist.toString()),
|
||||
() -> assertTrue(whitelist.contains(Constants.REFERRAL_SOURCE_ACTIVITY_FOLLOWUP), whitelist.toString()),
|
||||
() -> assertTrue(whitelist.contains(Constants.REFERRAL_SOURCE_SERVICE_STATION), whitelist.toString()),
|
||||
() -> assertTrue(whitelist.contains(Constants.REFERRAL_SOURCE_OPERATOR_ASSISTED), whitelist.toString())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -200,3 +200,56 @@ CREATE TABLE activities (
|
||||
created_at TIMESTAMP WITH TIME ZONE, updated_at TIMESTAMP WITH TIME ZONE, deleted_at TIMESTAMP WITH TIME ZONE
|
||||
);
|
||||
|
||||
-- ============================================================================
|
||||
-- Phase 13(V009 + V021 + V037 + V038):红娘服务三表
|
||||
-- 此前测试库完全缺失,导致 matchmaker 模块零真实行为覆盖、只能写假测试。
|
||||
-- 约束与生产 V038 对齐(status 白名单已含 liked/passed/confirmed_mutual)。
|
||||
-- ============================================================================
|
||||
|
||||
-- V009: matchmaker_services 红娘服务表
|
||||
CREATE TABLE matchmaker_services (
|
||||
id VARCHAR(36) PRIMARY KEY, user_id VARCHAR(36) NOT NULL, service_type VARCHAR(32) NOT NULL,
|
||||
description VARCHAR(1000), status VARCHAR(32) NOT NULL DEFAULT 'submitted',
|
||||
organization_id VARCHAR(36), assigned_operator_id VARCHAR(36),
|
||||
assigned_at TIMESTAMP WITH TIME ZONE, completed_at TIMESTAMP WITH TIME ZONE,
|
||||
referral_target_type VARCHAR(32), referral_target_id VARCHAR(36),
|
||||
referral_source_type VARCHAR(32) NOT NULL DEFAULT 'manual',
|
||||
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_matchmaker_service_type CHECK (service_type IN (
|
||||
'recommend_partner', 'review_preference', 'contact_intention',
|
||||
'join_activity', 'no_match_help', 'chat_help', 'other',
|
||||
'recommendation',
|
||||
'referral_counseling', 'referral_activity', 'referral_station'
|
||||
)),
|
||||
CONSTRAINT chk_matchmaker_status CHECK (status IN (
|
||||
'submitted', 'assigned', 'in_service', 'waiting_user', 'completed', 'cancelled'
|
||||
))
|
||||
);
|
||||
|
||||
-- V009: matchmaker_recommendations 红娘推荐表
|
||||
CREATE TABLE matchmaker_recommendations (
|
||||
id VARCHAR(36) PRIMARY KEY, service_id VARCHAR(36) NOT NULL, user_id VARCHAR(36) NOT NULL,
|
||||
target_user_id VARCHAR(36) NOT NULL, operator_id VARCHAR(36) NOT NULL, reason VARCHAR(1000),
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'pending_view',
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
viewed_at TIMESTAMP WITH TIME ZONE, responded_at TIMESTAMP WITH TIME ZONE,
|
||||
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
deleted_at TIMESTAMP WITH TIME ZONE,
|
||||
CONSTRAINT chk_rec_status CHECK (status IN (
|
||||
'pending_view', 'viewed', 'responded',
|
||||
'liked', 'passed', 'confirmed_mutual'
|
||||
))
|
||||
);
|
||||
|
||||
-- V009: matchmaker_followups 红娘跟进表
|
||||
CREATE TABLE matchmaker_followups (
|
||||
id VARCHAR(36) PRIMARY KEY, service_id VARCHAR(36) NOT NULL, operator_id VARCHAR(36) NOT NULL,
|
||||
followup_type VARCHAR(32), content VARCHAR(2000) NOT NULL, result_code VARCHAR(64),
|
||||
next_followup_at TIMESTAMP WITH TIME ZONE,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE,
|
||||
deleted_at TIMESTAMP WITH TIME ZONE
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user