fix(invariants): Phase 08 复验 —— 修复 INV-004 搜索/详情绕过 Block + matches.source 取值越界
复验(KEEP-VERIFY)在既有 Phase 08 实现中发现 3 处真实缺陷并修复:
D1(严重)DiscoverService.searchUsers 不含查看者身份,查询条件既无排除自己也无任何 Block 条件,被拉黑者仍可被搜到并点开 —— INV-004「搜索不可见」整体失效。改为接收 viewerUserId,排除自己,并用新增的 UserRelationMapper.findBlockedUserIdsBothWays(UNION 双向)一次取回后 notIn 批量排除,避免 N+1。
D2 详情路径(DiscoverService / UserProfileService)均无 Block 判断,知道 userId 即可读到被拉黑者资料。两条路径统一加双向判断并返回 null,由 Controller 转 404(不用「已隐藏」以免被用来探测存在);UserProfileService 中该判断排在隐私设置之前,拉黑不能被对方「允许搜索可见」覆盖。
D3(潜伏,仅 PostgreSQL 暴露)RelationshipService.like() 把关系类型值 "liked" 当 match source 写入 matches.source,违反 V003:91 的 chk_match_source。测试库 schema-h2.sql 该列是裸 VARCHAR(32)、无约束,故长期静默通过。改传 MATCH_SOURCE_USER_LIKE。canonical 路径 RelationService.like 传值本就正确,线上未触发。
测试保真度对齐:schema-h2.sql 补 chk_match_source(与 V003 逐字对齐)与 user_profiles 表(此前测试库无此表,资料查询无法端到端测试)。
新增 DiscoverBlockInvisibilityIntegrationTest(9 用例,@SpringBootTest 真实 H2 + 真实 SQL),覆盖正向/反向拉黑、对称性、排除自己、详情 404 与查自己不受限。P0FixVerificationTest 补 source 回归守卫。DiscoverServiceTest 同步构造签名。
负向验证:临时还原 D3 修复后,4 个用例因 CHK_MATCH_SOURCE 约束违反报错,证明守卫有效。
证据:后端全量 308 tests / 0 failures / 0 errors / 3 skipped(基线 299);UI 契约 13/13 GREEN。报告 docs/Phase-08-V11-Recovery-Completion-Report-2026-09-16.md。
同步 STATUS.md / JIRA_BACKLOG.md(EPIC-P08-V11 置 ✅)/ 客户需求基线计划 Phase 08 复选框;首轮报告加状态注记(其「Known Issues 无」已被推翻)。.gitignore 忽略 .workbuddy/。
This commit is contained in:
+4
-2
@@ -48,18 +48,20 @@ public class DiscoverController {
|
||||
if (maritalStatus != null) tempFilter.put("maritalStatus", maritalStatus);
|
||||
if (educationCode != null) tempFilter.put("educationCode", educationCode);
|
||||
|
||||
return ApiResponse.ok(discoverService.searchUsers(myGender, tempFilter, page, pageSize));
|
||||
return ApiResponse.ok(discoverService.searchUsers(userId, myGender, tempFilter, page, pageSize));
|
||||
}
|
||||
|
||||
/**
|
||||
* 查看用户详情
|
||||
* GET /api/v1/discover/users/{userId}
|
||||
*
|
||||
* INV-004:双向拉黑时 service 返回 null,这里按 404 处理(不泄露"存在但不可见")。
|
||||
*/
|
||||
@GetMapping("/users/{userId}")
|
||||
public ApiResponse<Map<String, Object>> getProfile(
|
||||
@AuthenticationPrincipal UserPrincipal principal,
|
||||
@PathVariable String userId) {
|
||||
Map<String, Object> profile = discoverService.getPublicProfile(userId);
|
||||
Map<String, Object> profile = discoverService.getPublicProfile(principal.id(), userId);
|
||||
if (profile == null) {
|
||||
return ApiResponse.error(com.danyang.match.platform.shared.enums.ApiResponseCode.NOT_FOUND, "用户不存在");
|
||||
}
|
||||
|
||||
+28
-3
@@ -1,7 +1,7 @@
|
||||
package com.danyang.match.platform.discover.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.danyang.match.platform.match.dto.RecommendationCandidateDTO;
|
||||
import com.danyang.match.platform.match.mapper.UserRelationMapper;
|
||||
import com.danyang.match.platform.profile.entity.UserProfile;
|
||||
import com.danyang.match.platform.profile.mapper.UserProfileMapper;
|
||||
import com.danyang.match.platform.shared.constant.Constants;
|
||||
@@ -14,29 +14,45 @@ import java.util.stream.Collectors;
|
||||
/**
|
||||
* 找对象搜索服务
|
||||
* 支持临时筛选(不修改用户择偶条件)
|
||||
*
|
||||
* INV-004(Block 优先级最高)在本模块的两条检索路径上都必须生效:
|
||||
* 列表搜索排除双向拉黑者,详情查看对双向拉黑者返回不存在。
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class DiscoverService {
|
||||
|
||||
private final UserProfileMapper userProfileMapper;
|
||||
private final UserRelationMapper relationMapper;
|
||||
|
||||
/**
|
||||
* 搜索用户列表(临时筛选)
|
||||
*
|
||||
* @param viewerUserId 当前登录用户(认证主体)—— 用于排除自己与双向拉黑者
|
||||
* @param myGender 当前用户性别(用于过滤相反性别)
|
||||
* @param tempFilter 临时筛选条件(不影响用户择偶条件)
|
||||
* @param page 页码
|
||||
* @param pageSize 每页数量
|
||||
* @return 搜索结果
|
||||
*/
|
||||
public Map<String, Object> searchUsers(String myGender, Map<String, Object> tempFilter,
|
||||
public Map<String, Object> searchUsers(String viewerUserId, String myGender, Map<String, Object> tempFilter,
|
||||
int page, int pageSize) {
|
||||
LambdaQueryWrapper<UserProfile> wrapper = new LambdaQueryWrapper<>();
|
||||
wrapper.ne(UserProfile::getGender, myGender)
|
||||
.eq(UserProfile::getProfileStatus, Constants.PROFILE_STATUS_APPROVED)
|
||||
.isNull(UserProfile::getDeletedAt);
|
||||
|
||||
// INV-004 搜索不可见:排除自己 + 双向拉黑者。
|
||||
// 用一次 UNION 查询取回全部拉黑 id 后批量 NOT IN,避免逐条 countBlockedEitherWay 造成 N+1。
|
||||
// viewerUserId 为 null 只出现在非法/未认证调用路径上,此时不做排除(上层鉴权已拒绝)。
|
||||
if (viewerUserId != null) {
|
||||
wrapper.ne(UserProfile::getUserId, viewerUserId);
|
||||
List<String> blockedUserIds = relationMapper.findBlockedUserIdsBothWays(viewerUserId);
|
||||
if (blockedUserIds != null && !blockedUserIds.isEmpty()) {
|
||||
wrapper.notIn(UserProfile::getUserId, blockedUserIds);
|
||||
}
|
||||
}
|
||||
|
||||
// 应用临时筛选条件
|
||||
if (tempFilter.containsKey("minAge")) {
|
||||
Integer minAge = (Integer) tempFilter.get("minAge");
|
||||
@@ -88,8 +104,17 @@ public class DiscoverService {
|
||||
|
||||
/**
|
||||
* 获取单个用户公开详情
|
||||
*
|
||||
* @param viewerUserId 查看者(认证主体)
|
||||
* @param userId 被查看者
|
||||
* @return 双向拉黑时返回 null,上层据此返回 404 —— 不返回"已隐藏"这类可被用来探测存在的响应
|
||||
*/
|
||||
public Map<String, Object> getPublicProfile(String userId) {
|
||||
public Map<String, Object> getPublicProfile(String viewerUserId, String userId) {
|
||||
// INV-004 搜索不可见:任一方向拉黑后互相不可见;查看自己的资料不受此限制
|
||||
if (viewerUserId != null && !viewerUserId.equals(userId)
|
||||
&& relationMapper.countBlockedEitherWay(viewerUserId, userId) > 0) {
|
||||
return null;
|
||||
}
|
||||
return userProfileMapper.findByUserId(userId)
|
||||
.map(profile -> {
|
||||
Map<String, Object> map = new HashMap<>();
|
||||
|
||||
@@ -50,6 +50,17 @@ public interface UserRelationMapper extends BaseMapper<UserRelation> {
|
||||
@Select("SELECT * FROM user_relations WHERE user_id = #{userId} AND relation_type = 'passed' AND deleted_at IS NULL ORDER BY created_at DESC LIMIT 100")
|
||||
List<UserRelation> findPassedByUser(String userId);
|
||||
|
||||
/**
|
||||
* 与我有拉黑关系的全部用户 id(双向 UNION 去重)—— 供列表/搜索批量排除。
|
||||
*
|
||||
* INV-004:Block 优先级最高,拉黑后「搜索不可见」是双向的:
|
||||
* 我拉黑的人看不到我,拉黑我的人也看不到我。单方向的 findBlockedByUser 不足以支撑该语义。
|
||||
*/
|
||||
@Select("SELECT target_user_id FROM user_relations WHERE user_id = #{userId} AND relation_type = 'blocked' AND deleted_at IS NULL "
|
||||
+ "UNION "
|
||||
+ "SELECT user_id FROM user_relations WHERE target_user_id = #{userId} AND relation_type = 'blocked' AND deleted_at IS NULL")
|
||||
List<String> findBlockedUserIdsBothWays(@Param("userId") String userId);
|
||||
|
||||
/** 插入关系(依赖唯一约束去重,返回 0 表示已存在) */
|
||||
@org.apache.ibatis.annotations.Insert("INSERT INTO user_relations (id, user_id, target_user_id, relation_type) VALUES (#{id}, #{userId}, #{targetUserId}, #{relationType})")
|
||||
int insertIgnore(UserRelation relation);
|
||||
|
||||
+9
@@ -8,6 +8,7 @@ import com.danyang.match.platform.profile.entity.UserPrivacySettings;
|
||||
import com.danyang.match.platform.profile.entity.UserProfile;
|
||||
import com.danyang.match.platform.profile.mapper.UserPreferenceMapper;
|
||||
import com.danyang.match.platform.profile.mapper.UserPrivacySettingsMapper;
|
||||
import com.danyang.match.platform.match.mapper.UserRelationMapper;
|
||||
import com.danyang.match.platform.profile.mapper.UserProfileMapper;
|
||||
import com.danyang.match.platform.shared.constant.Constants;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
@@ -25,6 +26,7 @@ public class UserProfileService {
|
||||
private final UserProfileMapper userProfileMapper;
|
||||
private final UserPreferenceMapper userPreferenceMapper;
|
||||
private final UserPrivacySettingsMapper privacySettingsMapper;
|
||||
private final UserRelationMapper relationMapper;
|
||||
|
||||
/**
|
||||
* 获取自己完整资料(含隐私设置)
|
||||
@@ -141,6 +143,13 @@ public class UserProfileService {
|
||||
UserProfile profile = userProfileMapper.findByUserId(targetUserId).orElse(null);
|
||||
if (profile == null) return null;
|
||||
|
||||
// INV-004:Block 优先级最高 —— 任一方向拉黑后互相不可见。
|
||||
// 必须排在隐私设置之前:拉黑是用户的显式阻断,不能被对方"允许搜索可见"的设置覆盖。
|
||||
if (viewerId != null && !viewerId.equals(targetUserId)
|
||||
&& relationMapper.countBlockedEitherWay(viewerId, targetUserId) > 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 隐私检查:若对方关闭了搜索可见性,则不展示
|
||||
UserPrivacySettings privacy = privacySettingsMapper.findByUserId(targetUserId).orElse(null);
|
||||
if (privacy != null && !privacy.getVisibleInSearch()) {
|
||||
|
||||
+4
-1
@@ -76,7 +76,10 @@ public class RelationshipService {
|
||||
result.put("mutual", mutual);
|
||||
|
||||
if (mutual) {
|
||||
createMatch(userId, targetUserId, Constants.RELATION_TYPE_LIKED);
|
||||
// matches.source 的取值域由 V003 的 chk_match_source 约束限定为
|
||||
// user_like / activity_mutual / matchmaker。此处必须传 MATCH_SOURCE_USER_LIKE;
|
||||
// 早期误传 RELATION_TYPE_LIKED("liked") 在 H2(测试库无该约束)能过,在 PostgreSQL 上必违反约束。
|
||||
createMatch(userId, targetUserId, Constants.MATCH_SOURCE_USER_LIKE);
|
||||
result.put("matchCreated", true);
|
||||
log.info("双向Match创建: userId={}, targetUserId={}", userId, targetUserId);
|
||||
}
|
||||
|
||||
+200
@@ -0,0 +1,200 @@
|
||||
package com.danyang.match.platform.discover;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.danyang.match.platform.discover.service.DiscoverService;
|
||||
import com.danyang.match.platform.match.entity.UserRelation;
|
||||
import com.danyang.match.platform.match.mapper.UserRelationMapper;
|
||||
import com.danyang.match.platform.match.service.RelationService;
|
||||
import com.danyang.match.platform.profile.entity.UserProfile;
|
||||
import com.danyang.match.platform.profile.mapper.UserProfileMapper;
|
||||
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 java.time.LocalDate;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* INV-004(Block 优先级最高)在「搜索/详情」两条检索路径上的端到端验证。
|
||||
*
|
||||
* 与 {@link DiscoverServiceTest} 的区别:本类跑真实 H2 库 + 真实 MyBatis SQL,
|
||||
* 验证 UNION 双向拉黑 SQL、NOT IN 批量排除、以及 404 语义确实生效,
|
||||
* 而不是只断言常量字符串。
|
||||
*
|
||||
* 历史缺陷(本轮修复):searchUsers 原先不接收查看者身份,
|
||||
* 导致「我拉黑的人」与「拉黑我的人」都能在搜索结果里被检索到 —— INV-004 的
|
||||
* "搜索不可见" 完全失效;getPublicProfile 同样无 Block 检查。
|
||||
*/
|
||||
@SpringBootTest
|
||||
@ActiveProfiles("test")
|
||||
class DiscoverBlockInvisibilityIntegrationTest {
|
||||
|
||||
@Autowired private DiscoverService discoverService;
|
||||
@Autowired private RelationService relationService;
|
||||
@Autowired private UserMapper userMapper;
|
||||
@Autowired private UserProfileMapper userProfileMapper;
|
||||
@Autowired private UserRelationMapper relationMapper;
|
||||
|
||||
private String viewer; // male
|
||||
private String other; // female
|
||||
private String third; // female
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
viewer = createUserWithProfile("viewer", "male");
|
||||
other = createUserWithProfile("other", "female");
|
||||
third = createUserWithProfile("third", "female");
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
// 关系按「涉及任一测试用户」清理(双向拉黑会产生反向行)
|
||||
List<UserRelation> relations = relationMapper.selectList(
|
||||
new LambdaQueryWrapper<UserRelation>()
|
||||
.in(UserRelation::getUserId, List.of(viewer, other, third))
|
||||
.or().in(UserRelation::getTargetUserId, List.of(viewer, other, third)));
|
||||
relations.forEach(r -> relationMapper.deleteById(r.getId()));
|
||||
|
||||
userProfileMapper.deleteById(viewer);
|
||||
userProfileMapper.deleteById(other);
|
||||
userProfileMapper.deleteById(third);
|
||||
userMapper.deleteById(viewer);
|
||||
userMapper.deleteById(other);
|
||||
userMapper.deleteById(third);
|
||||
}
|
||||
|
||||
// ==================== 搜索路径 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("INV-004 基线:无拉黑关系时,双方均可见")
|
||||
void searchShowsBothWhenNoBlock() {
|
||||
List<String> ids = searchIds(viewer);
|
||||
|
||||
assertTrue(ids.contains(other), "无拉黑关系时 other 应可见");
|
||||
assertTrue(ids.contains(third), "无拉黑关系时 third 应可见");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("INV-004 搜索不可见(正向):我拉黑的人不再出现在我的搜索里")
|
||||
void searchHidesUsersIBlocked() {
|
||||
relationService.block(viewer, other);
|
||||
|
||||
List<String> ids = searchIds(viewer);
|
||||
|
||||
assertFalse(ids.contains(other), "被我拉黑的人不应出现在我的搜索结果中");
|
||||
assertTrue(ids.contains(third), "无关用户 third 仍应可见(排除逻辑不得误伤)");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("INV-004 搜索不可见(反向):拉黑我的人不再出现在我的搜索里")
|
||||
void searchHidesUsersWhoBlockedMe() {
|
||||
// 关键用例:单向 findBlockedByUser 无法覆盖此场景
|
||||
relationService.block(other, viewer);
|
||||
|
||||
List<String> ids = searchIds(viewer);
|
||||
|
||||
assertFalse(ids.contains(other), "拉黑我的人不应出现在我的搜索结果中");
|
||||
assertTrue(ids.contains(third), "无关用户 third 仍应可见");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("INV-004 搜索不可见:拉黑者自己也搜不到被拉黑者(对称)")
|
||||
void searchIsSymmetricAfterBlock() {
|
||||
relationService.block(viewer, other);
|
||||
|
||||
List<String> viewerSide = searchIds(viewer);
|
||||
List<String> otherSide = searchIds(other);
|
||||
|
||||
assertFalse(viewerSide.contains(other), "viewer 搜不到 other");
|
||||
assertFalse(otherSide.contains(viewer), "other 也搜不到 viewer");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("搜索排除自己:结果里不应包含查看者本人")
|
||||
void searchExcludesSelf() {
|
||||
// third 也是 female,这里让 viewer 搜 female,自己(male)本就被性别过滤挡住;
|
||||
// 因此再用 third 作为查看者验证「排除自己」这一独立分支。
|
||||
List<String> ids = searchIds(third);
|
||||
|
||||
assertFalse(ids.contains(third), "搜索结果不应包含查看者自己");
|
||||
}
|
||||
|
||||
// ==================== 详情路径 ====================
|
||||
|
||||
@Test
|
||||
@DisplayName("INV-004 详情:无拉黑关系时可正常查看")
|
||||
void profileVisibleWithoutBlock() {
|
||||
Map<String, Object> profile = discoverService.getPublicProfile(viewer, other);
|
||||
|
||||
assertNotNull(profile, "无拉黑关系时详情应可见");
|
||||
assertEquals(other, profile.get("userId"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("INV-004 详情:我拉黑对方后返回 null(上层转 404)")
|
||||
void profileHiddenWhenIBlocked() {
|
||||
relationService.block(viewer, other);
|
||||
|
||||
assertNull(discoverService.getPublicProfile(viewer, other),
|
||||
"被我拉黑者的详情应不可见(返回 null 由上层转 404)");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("INV-004 详情:对方拉黑我后返回 null(反向)")
|
||||
void profileHiddenWhenBlockedMe() {
|
||||
relationService.block(other, viewer);
|
||||
|
||||
assertNull(discoverService.getPublicProfile(viewer, other),
|
||||
"拉黑我者的详情应不可见");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("详情:查看自己的资料不受 Block 限制")
|
||||
void profileOfSelfAlwaysVisible() {
|
||||
assertNotNull(discoverService.getPublicProfile(viewer, viewer),
|
||||
"查看自己的资料不应被 Block 规则拦截");
|
||||
}
|
||||
|
||||
// ==================== 辅助 ====================
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private List<String> searchIds(String viewerUserId) {
|
||||
String myGender = userProfileMapper.findByUserId(viewerUserId)
|
||||
.map(UserProfile::getGender).orElse(null);
|
||||
Map<String, Object> result = discoverService.searchUsers(
|
||||
viewerUserId, myGender, new HashMap<>(), 1, 50);
|
||||
|
||||
return ((List<Map<String, Object>>) result.get("records"))
|
||||
.stream().map(m -> (String) m.get("userId")).toList();
|
||||
}
|
||||
|
||||
private String createUserWithProfile(String name, String gender) {
|
||||
User u = new User();
|
||||
u.setWechatOpenid("discover_test_" + name + "_" + System.nanoTime());
|
||||
u.setAccountStatus(Constants.ACCOUNT_STATUS_NORMAL);
|
||||
u.setRelationshipStatus(Constants.RELATIONSHIP_STATUS_SEARCHING);
|
||||
u.setRecommendationEnabled(true);
|
||||
userMapper.insert(u);
|
||||
|
||||
UserProfile p = new UserProfile();
|
||||
p.setUserId(u.getId());
|
||||
p.setDisplayName("用户" + name);
|
||||
p.setGender(gender);
|
||||
p.setBirthDate(LocalDate.of(1993, 1, 1));
|
||||
p.setRegionCode("320681");
|
||||
p.setRegionName("江苏省镇江市丹阳市");
|
||||
p.setProfileCompletion(100);
|
||||
p.setProfileStatus(Constants.PROFILE_STATUS_APPROVED);
|
||||
userProfileMapper.insert(p);
|
||||
|
||||
return u.getId();
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,7 @@ class DiscoverServiceTest {
|
||||
|
||||
@Test
|
||||
void testAgeNull() {
|
||||
DiscoverService service = new DiscoverService(null);
|
||||
DiscoverService service = new DiscoverService(null, null);
|
||||
Integer age = ReflectionTestUtils.invokeMethod(service, "calculateAge", (java.time.LocalDate) null);
|
||||
assertEquals(0, age);
|
||||
}
|
||||
|
||||
@@ -204,6 +204,10 @@ class P0FixVerificationTest {
|
||||
// 2. 验证Match存在
|
||||
Match match = matchMapper.findActiveMatch(userA, userB).orElse(null);
|
||||
assertNotNull(match, "Match应已创建");
|
||||
// matches.source 必须落在 V003 chk_match_source 允许的取值域内。
|
||||
// 回归守卫:曾误传 RELATION_TYPE_LIKED("liked"),H2 无该约束时静默通过、PostgreSQL 上违反约束。
|
||||
assertEquals(Constants.MATCH_SOURCE_USER_LIKE, match.getSource(),
|
||||
"双向 Like 产生的 Match,source 应为 user_like");
|
||||
|
||||
// 3. 验证Conversation已自动创建
|
||||
var convs = conversationMapper.selectList(
|
||||
|
||||
@@ -12,11 +12,26 @@ CREATE TABLE user_relations (
|
||||
created_at TIMESTAMP WITH TIME ZONE, updated_at TIMESTAMP WITH TIME ZONE, deleted_at TIMESTAMP WITH TIME ZONE,
|
||||
UNIQUE (user_id, target_user_id, relation_type)
|
||||
);
|
||||
-- 与 V002__add_user_and_verification_tables.sql 的 user_profiles 对齐(列名/语义一致),
|
||||
-- 供 DiscoverService 搜索(INV-004 搜索不可见)等需要真实资料的用例使用。
|
||||
CREATE TABLE user_profiles (
|
||||
user_id VARCHAR(36) PRIMARY KEY, display_name VARCHAR(64), gender VARCHAR(16),
|
||||
birth_date DATE, height_cm INT, region_code VARCHAR(32), region_name VARCHAR(128),
|
||||
marital_status VARCHAR(32), children_status VARCHAR(32), education_code VARCHAR(32),
|
||||
occupation_code VARCHAR(32), work_region_code VARCHAR(32), smoking_status VARCHAR(32),
|
||||
drinking_status VARCHAR(32), long_term_danyang VARCHAR(32), future_danyang VARCHAR(32),
|
||||
relationship_goal VARCHAR(64), marriage_plan VARCHAR(32), self_intro CLOB,
|
||||
profile_completion INT DEFAULT 0, profile_status VARCHAR(32) DEFAULT 'in_progress',
|
||||
created_at TIMESTAMP WITH TIME ZONE, updated_at TIMESTAMP WITH TIME ZONE, deleted_at TIMESTAMP WITH TIME ZONE
|
||||
);
|
||||
CREATE TABLE matches (
|
||||
id VARCHAR(36) PRIMARY KEY, user_a_id VARCHAR(36), user_b_id VARCHAR(36), status VARCHAR(32), source VARCHAR(32),
|
||||
source_id VARCHAR(64), last_activity_at TIMESTAMP WITH TIME ZONE, unmatched_by VARCHAR(36), episode INT,
|
||||
matched_at TIMESTAMP WITH TIME ZONE, unmatched_at TIMESTAMP WITH TIME ZONE, successful_at TIMESTAMP WITH TIME ZONE,
|
||||
created_at TIMESTAMP WITH TIME ZONE, updated_at TIMESTAMP WITH TIME ZONE, deleted_at TIMESTAMP WITH TIME ZONE
|
||||
created_at TIMESTAMP WITH TIME ZONE, updated_at TIMESTAMP WITH TIME ZONE, deleted_at TIMESTAMP WITH TIME ZONE,
|
||||
-- 与 V003__add_review_and_relation_tables.sql:91 的生产约束保持一致。
|
||||
-- 测试库若缺此约束,形如 source='liked' 的非法取值会被静默放过,只在 PostgreSQL 上炸。
|
||||
CONSTRAINT chk_match_source CHECK (source IN ('user_like', 'activity_mutual', 'matchmaker'))
|
||||
);
|
||||
CREATE TABLE relationship_success_requests (
|
||||
id VARCHAR(36) PRIMARY KEY, match_id VARCHAR(36), initiated_by VARCHAR(36), status VARCHAR(32), confirmed_by VARCHAR(36),
|
||||
|
||||
Reference in New Issue
Block a user