feat: disable user messaging and retain公益 service guidance
CI/CD Pipeline / Test Shell Guard (Phase 23 Gate) (push) Canceled after 0s
CI/CD Pipeline / Backend Build & Test (push) Canceled after 0s
CI/CD Pipeline / Admin Web Build (push) Canceled after 0s
CI/CD Pipeline / Mini Program Compile Check (push) Canceled after 0s
CI/CD Pipeline / Migration Validation (push) Canceled after 0s
CI/CD Pipeline / UI Contracts (source-structure gates) (push) Canceled after 0s
CI/CD Pipeline / E2E (live stack) (push) Canceled after 0s

This commit is contained in:
WorkBuddy
2026-10-15 13:38:17 +08:00
parent 3c2b967274
commit 8b505d9133
37 changed files with 150 additions and 189 deletions
@@ -0,0 +1,44 @@
package com.danyang.match.platform.chat.config;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
/**
* 产品当前不提供用户之间的即时聊天。
*
* <p>在 HTTP 和 WebSocket 握手进入 Controller/Handler 前统一拒绝,
* 防止只隐藏小程序入口却留下可调用的通信通道。
*/
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class ChatDisabledFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String path = request.getRequestURI();
String context = request.getContextPath();
if (context != null && !context.isEmpty() && path.startsWith(context)) {
path = path.substring(context.length());
}
if (path.equals("/conversations") || path.startsWith("/conversations/")
|| path.equals("/ws/chat") || path.startsWith("/ws/chat/")) {
response.setStatus(HttpServletResponse.SC_GONE);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write(
"{\"success\":false,\"code\":\"CHAT_DISABLED\","
+ "\"message\":\"平台未提供用户之间的即时聊天功能\",\"data\":null}");
return;
}
filterChain.doFilter(request, response);
}
}
@@ -1,7 +1,5 @@
package com.danyang.match.platform.match.controller; package com.danyang.match.platform.match.controller;
import com.danyang.match.platform.chat.entity.Conversation;
import com.danyang.match.platform.chat.service.ChatService;
import com.danyang.match.platform.match.service.RelationService; import com.danyang.match.platform.match.service.RelationService;
import com.danyang.match.platform.match.service.RelationService.LikeResult; import com.danyang.match.platform.match.service.RelationService.LikeResult;
import com.danyang.match.platform.shared.constant.Constants; import com.danyang.match.platform.shared.constant.Constants;
@@ -11,7 +9,6 @@ import com.danyang.match.platform.shared.idempotency.Idempotent;
import com.danyang.match.platform.shared.security.principal.UserPrincipal; import com.danyang.match.platform.shared.security.principal.UserPrincipal;
import lombok.Data; import lombok.Data;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
@@ -22,18 +19,16 @@ import org.springframework.web.bind.annotation.*;
* DELETE /api/v1/likes/{targetUserId} — A 取消 Like B * DELETE /api/v1/likes/{targetUserId} — A 取消 Like B
* *
* 核心规则:INV-005 未 Mutual 不可聊天 * 核心规则:INV-005 未 Mutual 不可聊天
* 响应契约对齐 miniapp/types/index.d.ts 的 LikeResult:{ relationStatus, mutualMatch, matchedConversationId } * 响应返回关系状态和 Match 结果,不创建用户会话。
* *
* 说明:原 /relations/{targetUserId}/like(2026-09-10 版)已被本控制器取代,调用方统一走 /likes。 * 说明:原 /relations/{targetUserId}/like(2026-09-10 版)已被本控制器取代,调用方统一走 /likes。
*/ */
@Slf4j
@RestController @RestController
@RequestMapping("/likes") @RequestMapping("/likes")
@RequiredArgsConstructor @RequiredArgsConstructor
public class LikeController { public class LikeController {
private final RelationService relationService; private final RelationService relationService;
private final ChatService chatService;
@Idempotent @Idempotent
@PostMapping @PostMapping
@@ -44,7 +39,7 @@ public class LikeController {
return ApiResponse.error(ApiResponseCode.BAD_REQUEST, "targetUserId不能为空"); return ApiResponse.error(ApiResponseCode.BAD_REQUEST, "targetUserId不能为空");
} }
LikeResult result = relationService.like(principal.id(), req.targetUserId); LikeResult result = relationService.like(principal.id(), req.targetUserId);
return ApiResponse.ok(LikeResponse.from(result, ensureConversation(result, principal.id(), req.targetUserId))); return ApiResponse.ok(LikeResponse.from(result));
} }
/** /**
@@ -62,20 +57,8 @@ public class LikeController {
/** /**
* Mutual 达成时确保会话存在,并返回会话 id —— 前端据此直接跳转聊天页。 * Mutual 达成时确保会话存在,并返回会话 id —— 前端据此直接跳转聊天页。
* *
* - getOrCreateConversation 幂等,不会重复建会话
* - 建会话失败不应让 Like 整体失败(Like 已落库),故降级为 null,前端仅提示不跳转
*/ */
private String ensureConversation(LikeResult result, String userId, String targetUserId) { // 当前产品不提供用户间会话;Match 成功后仅展示结果和公益服务入口。
if (!result.mutual || result.match == null) return null;
try {
Conversation conv = chatService.getOrCreateConversation(result.match.getId(), userId, targetUserId);
return conv.getId();
} catch (RuntimeException e) {
log.warn("Mutual 达成但会话创建失败,已降级:user={}, target={}, matchId={}, reason={}",
userId, targetUserId, result.match.getId(), e.getMessage());
return null;
}
}
// ==================== DTO ==================== // ==================== DTO ====================
@@ -89,16 +72,14 @@ public class LikeController {
public static class LikeResponse { public static class LikeResponse {
private String relationStatus; private String relationStatus;
private boolean mutualMatch; private boolean mutualMatch;
private String matchedConversationId;
private String matchId; private String matchId;
public static LikeResponse from(LikeResult r, String matchedConversationId) { public static LikeResponse from(LikeResult r) {
LikeResponse resp = new LikeResponse(); LikeResponse resp = new LikeResponse();
resp.relationStatus = r.mutual resp.relationStatus = r.mutual
? Constants.RELATION_STATUS_MUTUAL_MATCH ? Constants.RELATION_STATUS_MUTUAL_MATCH
: Constants.RELATION_STATUS_LIKED; : Constants.RELATION_STATUS_LIKED;
resp.mutualMatch = r.mutual; resp.mutualMatch = r.mutual;
resp.matchedConversationId = matchedConversationId;
resp.matchId = r.match != null ? r.match.getId() : null; resp.matchId = r.match != null ? r.match.getId() : null;
return resp; return resp;
} }
@@ -251,16 +251,7 @@ public class RelationshipService {
matchMapper.insert(match); matchMapper.insert(match);
} }
// V1.1: 活动互选不自动创建Conversation,需双方确认后由confirmActivityInterest处理 // 当前产品不提供用户间聊天;Match 仅用于结果判定和公益服务引导。
if (autoCreateConversation) {
try {
chatService.getOrCreateConversation(match.getId(), userId, targetUserId);
log.info("Match后自动创建会话: matchId={}, users={}", match.getId(), userId + "," + targetUserId);
} catch (Exception e) {
log.warn("创建会话失败,Match已创建但聊天可能不可用: matchId={}, error={}",
match.getId(), e.toString(), e);
}
}
return match; return match;
} }
@@ -286,7 +277,6 @@ public class RelationshipService {
result.put("matchReused", true); result.put("matchReused", true);
result.put("matchId", existingMatch.get().getId()); result.put("matchId", existingMatch.get().getId());
result.put("status", existingMatch.get().getStatus()); result.put("status", existingMatch.get().getStatus());
result.put("conversationAutoCreated", true);
log.info("活动互选复用已有Match: userId={}, partnerId={}, matchId={}", userId, partnerId, existingMatch.get().getId()); log.info("活动互选复用已有Match: userId={}, partnerId={}, matchId={}", userId, partnerId, existingMatch.get().getId());
return result; return result;
} }
@@ -299,20 +289,10 @@ public class RelationshipService {
// 3. 创建新 Match Episode(source=activity_mutual,不自动创建 Conversation) // 3. 创建新 Match Episode(source=activity_mutual,不自动创建 Conversation)
Match match = createMatch(userId, partnerId, Constants.MATCH_SOURCE_ACTIVITY_MUTUAL, false); Match match = createMatch(userId, partnerId, Constants.MATCH_SOURCE_ACTIVITY_MUTUAL, false);
// V1.1 Phase 16 修复(Gate:互选确认后双方可沟通):双确认后必须开启会话。
// 此前此处未建会话,仅靠首条消息惰性创建,导致「双方可沟通」的入口不可见。
// 现显式开启,保证互选成功后聊天入口立即可见。
try {
chatService.getOrCreateConversation(match.getId(), match.getUserAId(), match.getUserBId());
} catch (Exception e) {
log.warn("活动互选开启会话失败(Match 已创建): matchId={}, error={}", match.getId(), e.toString(), e);
}
Map<String, Object> result = new HashMap<>(); Map<String, Object> result = new HashMap<>();
result.put("matchCreated", true); result.put("matchCreated", true);
result.put("matchId", match.getId()); result.put("matchId", match.getId());
result.put("priorUnmatch", priorUnmatchExists); result.put("priorUnmatch", priorUnmatchExists);
result.put("conversationAutoCreated", true);
log.info("活动互选创建新Match Episode: userId={}, partnerId={}, priorUnmatch={}, matchId={}", log.info("活动互选创建新Match Episode: userId={}, partnerId={}, priorUnmatch={}, matchId={}",
userId, partnerId, priorUnmatchExists, match.getId()); userId, partnerId, priorUnmatchExists, match.getId());
return result; return result;
@@ -73,7 +73,7 @@ class ActivityMutualConsentServiceTest {
@Test @Test
@DisplayName("Gate: 双确认后形成 Match 并开启 Conversation(此前只打日志,Gate 失败)") @DisplayName("Gate: 双确认后形成 Match 并开启 Conversation(此前只打日志,Gate 失败)")
void doubleConfirmCreatesMatchAndConversation() { void doubleConfirmCreatesMatchWithoutConversation() {
// 第一阶段:建立互选同意记录(二阶段同意) // 第一阶段:建立互选同意记录(二阶段同意)
consentService.createMutualConsent(activityId, userA, userB); consentService.createMutualConsent(activityId, userA, userB);
@@ -95,8 +95,8 @@ class ActivityMutualConsentServiceTest {
assertEquals(Constants.MATCH_SOURCE_ACTIVITY_MUTUAL, match.getSource()); assertEquals(Constants.MATCH_SOURCE_ACTIVITY_MUTUAL, match.getSource());
// 会话必须已开启(Gate:互选确认后双方可沟通) // 会话必须已开启(Gate:互选确认后双方可沟通)
assertTrue(conversationMapper.findByMatchId(match.getId()).isPresent(), assertTrue(conversationMapper.findByMatchId(match.getId()).isEmpty(),
"双确认后必须已开启 Conversation"); "双确认后不得创建用户聊天 Conversation");
// 溯源字段:consent 记录应绑定 match_id // 溯源字段:consent 记录应绑定 match_id
ActivityMutualConsent consent = consentMapper.findByActivityPair(activityId, userA, userB).orElseThrow(); ActivityMutualConsent consent = consentMapper.findByActivityPair(activityId, userA, userB).orElseThrow();
@@ -71,6 +71,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
@SpringBootTest @SpringBootTest
@ActiveProfiles("test") @ActiveProfiles("test")
@AutoConfigureMockMvc @AutoConfigureMockMvc
@Disabled("用户端即时聊天已按产品方案下线")
class ChatPhase09IntegrationTest { class ChatPhase09IntegrationTest {
@Autowired private MockMvc mockMvc; @Autowired private MockMvc mockMvc;
@@ -94,6 +94,7 @@ class P0FixVerificationTest {
// ==================== P0-1: Block不变量验证 ==================== // ==================== P0-1: Block不变量验证 ====================
@Test @Test
@Disabled("用户端即时聊天已按产品方案下线")
@DisplayName("INV-003: Block后A不可向B发送消息") @DisplayName("INV-003: Block后A不可向B发送消息")
void testBlockPreventsChat() { void testBlockPreventsChat() {
relationshipService.like(userA, userB); relationshipService.like(userA, userB);
@@ -213,7 +214,7 @@ class P0FixVerificationTest {
var convs = conversationMapper.selectList( var convs = conversationMapper.selectList(
new LambdaQueryWrapper<com.danyang.match.platform.chat.entity.Conversation>() new LambdaQueryWrapper<com.danyang.match.platform.chat.entity.Conversation>()
.eq(com.danyang.match.platform.chat.entity.Conversation::getMatchId, match.getId())); .eq(com.danyang.match.platform.chat.entity.Conversation::getMatchId, match.getId()));
assertFalse(convs.isEmpty(), "Match后应自动创建Conversation"); assertTrue(convs.isEmpty(), "Match 后不得自动创建 Conversation");
} }
// ==================== P1-1: Required全字段过滤验证 ==================== // ==================== P1-1: Required全字段过滤验证 ====================
@@ -258,12 +258,12 @@ class MatchmakerActionGuardTest {
// ==================== MM-ACTION-002:会话/关系开启无 operator 触达面 ==================== // ==================== MM-ACTION-002:会话/关系开启无 operator 触达面 ====================
@Test @Test
@DisplayName("MM-ACTION-002(负向): operator token 打会话开启端点 → 403(match/conversation 仅用户路径)") @DisplayName("MM-ACTION-002(负向): operator token 打已下线会话端点 → 410")
void operatorCannotOpenConversationFromMatch() throws Exception { void operatorCannotOpenConversationFromMatch() throws Exception {
// 认证层即拒绝(hasRole("USER")),无需真实 match 存在 // 用户间会话已全局下线,先于角色鉴权返回 CHAT_DISABLED。
mockMvc.perform(post("/conversations/from-match/{matchId}", "no-such-match") mockMvc.perform(post("/conversations/from-match/{matchId}", "no-such-match")
.header("Authorization", "Bearer " + operatorToken)) .header("Authorization", "Bearer " + operatorToken))
.andExpect(status().isForbidden()); .andExpect(status().isGone());
} }
// ==================== MM-ACTION-003:must-confirm 语义锁定 ==================== // ==================== MM-ACTION-003:must-confirm 语义锁定 ====================
@@ -237,7 +237,7 @@ class RelationshipProgressPhase12Test {
// 旧 Match 有会话 // 旧 Match 有会话
Long oldConvCount = conversationMapper.selectCount( Long oldConvCount = conversationMapper.selectCount(
new LambdaQueryWrapper<Conversation>().eq(Conversation::getMatchId, first.getId())); new LambdaQueryWrapper<Conversation>().eq(Conversation::getMatchId, first.getId()));
assertEquals(1L, oldConvCount, "首个 Match 应有 1 个会话"); assertEquals(0L, oldConvCount, "首个 Match 不应有用户聊天会话");
// Unmatch // Unmatch
Match m1 = matchMapper.findById(first.getId()).orElseThrow(); Match m1 = matchMapper.findById(first.getId()).orElseThrow();
@@ -255,10 +255,10 @@ class RelationshipProgressPhase12Test {
new LambdaQueryWrapper<Conversation>().eq(Conversation::getMatchId, second.getId())), new LambdaQueryWrapper<Conversation>().eq(Conversation::getMatchId, second.getId())),
"新 Match 不应自动继承旧会话(旧聊天权限不恢复)"); "新 Match 不应自动继承旧会话(旧聊天权限不恢复)");
// 且旧会话仍挂在旧 Match 上,不会被新 Match 复用 // 历史数据为空:本产品不再创建用户聊天会话
assertEquals(1L, conversationMapper.selectCount( assertEquals(0L, conversationMapper.selectCount(
new LambdaQueryWrapper<Conversation>().eq(Conversation::getMatchId, first.getId())), new LambdaQueryWrapper<Conversation>().eq(Conversation::getMatchId, first.getId())),
"旧会话应仍属于旧 Match"); "旧 Match 也不应存在用户聊天会话");
} }
// ==================== 拒绝 / 恢复 / 退出 ==================== // ==================== 拒绝 / 恢复 / 退出 ====================
+20
View File
@@ -0,0 +1,20 @@
# 用户端无即时通讯边界(2026-10-15)
## 已确认范围
- 小程序全流程不展示微信群二维码,也不展示或交换手机号、微信号、QQ 等个人联系方式。
- 小程序不提供用户之间的私信、聊天、会话列表或 WebSocket 双向通讯。
- 双向意向匹配只产生平台系统通知,并引导用户查看公益活动、公益服务站和公益红娘服务。
- 活动签到二维码属于活动现场核验,不属于群二维码,继续保留。
## 实施边界
- Like 和活动互选不再创建新 Conversation,不返回 `matchedConversationId`。
- 后端 `/conversations`、`/conversations/**`、`/ws/chat` 返回 `410 CHAT_DISABLED`;历史聊天数据保留,不再新增或向用户端开放。
- Nginx `/ws/` 直接返回 `410 CHAT_DISABLED`,避免网关继续代理聊天连接。
- 小程序不注册消息 Tab、消息页和聊天页;历史源码暂保留用于数据迁移和回归兼容,但不在运行路由中暴露。
## 验收
- `tests/miniapp/no_user_im_contract_test.py`:通过。
- 仍需在后端容器完成完整 `mvn test -B`,并验证运行中的 REST 聊天接口返回 410。
+5
View File
@@ -899,3 +899,8 @@ docker run --rm --dns 223.5.5.5 --dns 114.114.114.114 `
- 后端镜像已按当前源码重建成功;健康检查通过,未认证通知接口返回 401。 - 后端镜像已按当前源码重建成功;健康检查通过,未认证通知接口返回 401。
- 活栈 E2E 最新结果:55 passed;剩余 8 errors 为测试容器缺少 Docker CLI,另有活动互选测试依赖容器内 `docker exec`,不能计为完整 E2E 通过。 - 活栈 E2E 最新结果:55 passed;剩余 8 errors 为测试容器缺少 Docker CLI,另有活动互选测试依赖容器内 `docker exec`,不能计为完整 E2E 通过。
- 证据:`.mimosa/core-e2e-postgres-test.log`。 - 证据:`.mimosa/core-e2e-postgres-test.log`。
## 2026-10-15 无用户 IM 改造
- 已按确认方案关闭小程序用户私信/聊天入口,不展示微信群二维码。
- 匹配成功保留平台系统通知,并引导公益活动、服务站、红娘服务。
- 后端与 Nginx 聊天入口返回 CHAT_DISABLED/410;完整 Maven 回归与运行态验收待本轮完成。
+3 -9
View File
@@ -147,16 +147,10 @@ http {
proxy_read_timeout 60s; proxy_read_timeout 60s;
} }
# WebSocket proxy # 用户间即时聊天已下线,避免网关继续暴露 WebSocket 入口。
location /ws/ { location /ws/ {
limit_req zone=ws burst=20 nodelay; default_type application/json;
proxy_pass http://backend; return 410 '{"success":false,"code":"CHAT_DISABLED","message":"平台未提供用户之间的即时聊天功能","data":null}';
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_read_timeout 3600s;
} }
# Auth endpoints (strict rate limiting) # Auth endpoints (strict rate limiting)
+3 -10
View File
@@ -68,17 +68,10 @@ http {
add_header Pragma "no-cache"; add_header Pragma "no-cache";
} }
# WebSocket proxy # 用户间即时聊天已下线,避免网关继续暴露 WebSocket 入口。
location /ws/ { location /ws/ {
proxy_pass $backend_upstream; default_type application/json;
proxy_http_version 1.1; return 410 '{"success":false,"code":"CHAT_DISABLED","message":"平台未提供用户之间的即时聊天功能","data":null}';
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 3600s;
} }
# Auth endpoints - stricter rate limit # Auth endpoints - stricter rate limit
-8
View File
@@ -5,7 +5,6 @@
"pages": [ "pages": [
"pages/index/index", "pages/index/index",
"pages/discover/discover", "pages/discover/discover",
"pages/messages/messages",
"pages/activity/activity", "pages/activity/activity",
"pages/mine/mine", "pages/mine/mine",
"pages/login/login", "pages/login/login",
@@ -23,7 +22,6 @@
"pages/review-rejected/review-rejected", "pages/review-rejected/review-rejected",
"pages/profile-detail/profile-detail", "pages/profile-detail/profile-detail",
"pages/fate-center/fate-center", "pages/fate-center/fate-center",
"pages/chat/chat",
"pages/activity-detail/activity-detail", "pages/activity-detail/activity-detail",
"pages/activity-register/activity-register", "pages/activity-register/activity-register",
"pages/my-activities/my-activities", "pages/my-activities/my-activities",
@@ -97,12 +95,6 @@
"iconPath": "assets/tabbar/discover.png", "iconPath": "assets/tabbar/discover.png",
"selectedIconPath": "assets/tabbar/discover-active.png" "selectedIconPath": "assets/tabbar/discover-active.png"
}, },
{
"pagePath": "pages/messages/messages",
"text": "消息",
"iconPath": "assets/tabbar/message.png",
"selectedIconPath": "assets/tabbar/message-active.png"
},
{ {
"pagePath": "pages/activity/activity", "pagePath": "pages/activity/activity",
"text": "活动", "text": "活动",
+1 -1
View File
@@ -173,7 +173,7 @@ exports.ERROR_USER_MESSAGE = {
[exports.BUSINESS_ERROR_CODE.PROFILE_NOT_APPROVED]: '当前资料尚未通过审核', [exports.BUSINESS_ERROR_CODE.PROFILE_NOT_APPROVED]: '当前资料尚未通过审核',
[exports.BUSINESS_ERROR_CODE.ACCOUNT_LIMITED]: '账号已被限制,详情请查看账号状态', [exports.BUSINESS_ERROR_CODE.ACCOUNT_LIMITED]: '账号已被限制,详情请查看账号状态',
[exports.BUSINESS_ERROR_CODE.TARGET_UNAVAILABLE]: '该用户当前不可查看或联系', [exports.BUSINESS_ERROR_CODE.TARGET_UNAVAILABLE]: '该用户当前不可查看或联系',
[exports.BUSINESS_ERROR_CODE.MATCH_REQUIRED]: '双方互相喜欢后才能开始聊天', [exports.BUSINESS_ERROR_CODE.MATCH_REQUIRED]: '当前平台未提供用户间即时聊天功能',
[exports.BUSINESS_ERROR_CODE.ACTIVITY_FULL]: '本次活动报名人数已满', [exports.BUSINESS_ERROR_CODE.ACTIVITY_FULL]: '本次活动报名人数已满',
[exports.BUSINESS_ERROR_CODE.ACTIVITY_CLOSED]: '本次活动报名已结束', [exports.BUSINESS_ERROR_CODE.ACTIVITY_CLOSED]: '本次活动报名已结束',
[exports.BUSINESS_ERROR_CODE.ALREADY_APPLIED]: '你已经提交过申请', [exports.BUSINESS_ERROR_CODE.ALREADY_APPLIED]: '你已经提交过申请',
-8
View File
@@ -2,7 +2,6 @@
"pages": [ "pages": [
"pages/index/index", "pages/index/index",
"pages/discover/discover", "pages/discover/discover",
"pages/messages/messages",
"pages/activity/activity", "pages/activity/activity",
"pages/mine/mine", "pages/mine/mine",
"pages/login/login", "pages/login/login",
@@ -20,7 +19,6 @@
"pages/review-rejected/review-rejected", "pages/review-rejected/review-rejected",
"pages/profile-detail/profile-detail", "pages/profile-detail/profile-detail",
"pages/fate-center/fate-center", "pages/fate-center/fate-center",
"pages/chat/chat",
"pages/activity-detail/activity-detail", "pages/activity-detail/activity-detail",
"pages/activity-register/activity-register", "pages/activity-register/activity-register",
"pages/my-activities/my-activities", "pages/my-activities/my-activities",
@@ -68,12 +66,6 @@
"iconPath": "assets/tabbar/discover.png", "iconPath": "assets/tabbar/discover.png",
"selectedIconPath": "assets/tabbar/discover-active.png" "selectedIconPath": "assets/tabbar/discover-active.png"
}, },
{
"pagePath": "pages/messages/messages",
"text": "消息",
"iconPath": "assets/tabbar/message.png",
"selectedIconPath": "assets/tabbar/message-active.png"
},
{ {
"pagePath": "pages/activity/activity", "pagePath": "pages/activity/activity",
"text": "活动", "text": "活动",
@@ -50,9 +50,4 @@ Page({
}); });
}, },
startChat(userId) {
wx.navigateTo({
url: `/pages/chat/chat?userId=${userId}&activityId=${this.data.activityId}`
});
}
}); });
@@ -24,7 +24,7 @@
<text class="match-meta">{{item.age}}岁 · {{item.locationName || ''}}</text> <text class="match-meta">{{item.age}}岁 · {{item.locationName || ''}}</text>
</view> </view>
<view class="match-actions" catchtap> <view class="match-actions" catchtap>
<button class="btn-chat" bindtap="startChat" data-user-id="{{item.targetUserId}}">聊天</button> <text class="match-service-hint">可查看公益服务</text>
</view> </view>
</view> </view>
</view> </view>
@@ -18,8 +18,4 @@ Page({
} catch (e) { console.error(e); this.setData({ loading: false }); } } catch (e) { console.error(e); this.setData({ loading: false }); }
}, },
openChat(e: any) {
const partnerId = e.currentTarget.dataset.partnerid;
wx.navigateTo({ url: `/pages/chat/chat?partnerId=${partnerId}` });
}
}); });
@@ -27,7 +27,7 @@
<button class="btn-waiting" disabled>等待对方确认</button> <button class="btn-waiting" disabled>等待对方确认</button>
</view> </view>
<view class="card-actions" wx:if="{{item.bothConfirmed}}"> <view class="card-actions" wx:if="{{item.bothConfirmed}}">
<button class="btn-chat" bindtap="openChat" data-partnerid="{{item.partnerId}}">开启聊天</button> <text class="service-hint">双方已确认,可查看公益活动和服务站</text>
</view> </view>
</view> </view>
</block> </block>
@@ -55,10 +55,7 @@ Page({
}); });
if (res.statusCode === 200 && res.data.success) { if (res.statusCode === 200 && res.data.success) {
this.setData({ myConfirmed: true, bothConfirmed: res.data.data.bothConfirmed }); this.setData({ myConfirmed: true, bothConfirmed: res.data.data.bothConfirmed });
if (res.data.data.bothConfirmed) { if (res.data.data.bothConfirmed) wx.showToast({ title: '双方已确认,可查看公益服务', icon: 'success' });
wx.showToast({ title: '双方已确认!', icon: 'success' });
setTimeout(() => this.openChat(), 1500);
}
} }
} catch (e) { wx.showToast({ title: '操作失败', icon: 'none' }); } } catch (e) { wx.showToast({ title: '操作失败', icon: 'none' }); }
}, },
@@ -76,7 +73,4 @@ Page({
} catch (e) { wx.showToast({ title: '操作失败', icon: 'none' }); } } catch (e) { wx.showToast({ title: '操作失败', icon: 'none' }); }
}, },
openChat() {
wx.navigateTo({ url: `/pages/chat/chat?partnerId=${this.data.partnerId}` });
}
}); });
@@ -7,21 +7,20 @@
<view class="status-section" wx:if="{{bothConfirmed}}"> <view class="status-section" wx:if="{{bothConfirmed}}">
<view class="success-icon">✅</view> <view class="success-icon">✅</view>
<text class="success-title">双方已确认!</text> <text class="success-title">双方已确认!</text>
<text class="success-hint">你们可以开始聊天了</text> <text class="success-hint">双方意向已确认,可查看公益活动和服务站</text>
<button class="chat-btn" bindtap="openChat">开启聊天</button>
</view> </view>
<view class="status-section" wx:elif="{{myConfirmed}}"> <view class="status-section" wx:elif="{{myConfirmed}}">
<view class="waiting-icon">⏳</view> <view class="waiting-icon">⏳</view>
<text class="waiting-title">已发送邀请</text> <text class="waiting-title">已发送邀请</text>
<text class="waiting-hint">对方确认后你们可以开始聊天</text> <text class="waiting-hint">对方确认后将收到平台通知</text>
<button class="decline-btn" bindtap="onDecline">暂不联系</button> <button class="decline-btn" bindtap="onDecline">暂不联系</button>
</view> </view>
<view class="status-section" wx:else> <view class="status-section" wx:else>
<view class="pending-icon">💬</view> <view class="pending-icon">💬</view>
<text class="pending-title">向对方表达兴趣</text> <text class="pending-title">向对方表达兴趣</text>
<text class="pending-hint">双方确认后才能开启线上沟通</text> <text class="pending-hint">双方确认后将收到平台通知,并可查看公益服务</text>
<button class="confirm-btn" bindtap="onConfirm">确认开启沟通</button> <button class="confirm-btn" bindtap="onConfirm">确认开启沟通</button>
</view> </view>
</view> </view>
+1 -4
View File
@@ -305,10 +305,7 @@ Page({
const result = await matchStore.like(userId); const result = await matchStore.like(userId);
this.setData({ users: this.data.users.filter((u) => u.userId !== userId) }); this.setData({ users: this.data.users.filter((u) => u.userId !== userId) });
if (result?.mutualMatch) { if (result?.mutualMatch) {
appStore.toastSuccess('双向喜欢!可以开始聊天了'); appStore.toastSuccess('双向意向匹配成功,可查看公益服务');
if (result.matchedConversationId) {
wx.navigateTo({ url: to.chat(result.matchedConversationId) });
}
} else { } else {
appStore.toastSuccess('已表达喜欢'); appStore.toastSuccess('已表达喜欢');
} }
+2 -10
View File
@@ -141,12 +141,7 @@ Page({
wx.navigateTo({ url: to.detail(userId) }); wx.navigateTo({ url: to.detail(userId) });
}, },
/** 互相喜欢 → 开始聊天(已 Mutual,符合第 3 条) */ /** 互相喜欢 → 查看资料和公益服务 */
onStartChat(e) {
const conversationId = e.currentTarget.dataset.conversationId;
if (!conversationId) return;
wx.navigateTo({ url: to.chat(conversationId) });
},
/** /**
* 喜欢我的人 → 喜欢 TA(like-back) * 喜欢我的人 → 喜欢 TA(like-back)
@@ -163,12 +158,9 @@ Page({
likersList: matchStore.state.likersList.filter((u) => u.userId !== userId), likersList: matchStore.state.likersList.filter((u) => u.userId !== userId),
}); });
if (result && result.mutualMatch) { if (result && result.mutualMatch) {
appStore.toastSuccess('双向喜欢!可以开始聊天了'); appStore.toastSuccess('双向意向匹配成功,可查看公益服务');
// 刷新互相喜欢列表 // 刷新互相喜欢列表
try { await matchStore.fetchMutualList({ pageSize: PAGE_SIZE }); } catch (e2) { /* ignore */ } try { await matchStore.fetchMutualList({ pageSize: PAGE_SIZE }); } catch (e2) { /* ignore */ }
if (result.matchedConversationId) {
wx.navigateTo({ url: to.chat(result.matchedConversationId) });
}
} else { } else {
appStore.toastSuccess('已表达喜欢'); appStore.toastSuccess('已表达喜欢');
} }
+2 -2
View File
@@ -34,7 +34,7 @@
<view class="item" wx:for="{{mutualList}}" wx:key="conversationId"> <view class="item" wx:for="{{mutualList}}" wx:key="conversationId">
<ui-user-card user="{{item}}" bind:tap="onViewDetail"> <ui-user-card user="{{item}}" bind:tap="onViewDetail">
<view slot="actions"> <view slot="actions">
<button class="btn-primary" catchtap="onStartChat" data-conversation-id="{{item.conversationId}}">开始聊天</button> <button class="btn-primary" catchtap="onViewDetail" data-user-id="{{item.userId}}">查看资料</button>
</view> </view>
</ui-user-card> </ui-user-card>
</view> </view>
@@ -45,7 +45,7 @@
wx:else wx:else
illustration="heart" illustration="heart"
title="还没有互相喜欢的人" title="还没有互相喜欢的人"
description="去「找对象」遇见合适的人,互相喜欢就能聊天啦" description="去「找对象」遇见合适的人,双方匹配后可查看公益服务"
actions="{{[{text:'去「找对象', event:'discover', variant:'primary'}]}}" actions="{{[{text:'去「找对象', event:'discover', variant:'primary'}]}}"
bind:action="onEmptyAction" bind:action="onEmptyAction"
/> />
+3 -6
View File
@@ -4,7 +4,7 @@
*/ */
const { authStore, appStore, matchStore } = require('../../stores') const { authStore, appStore, matchStore } = require('../../stores')
const api = require('../../utils/api') const api = require('../../utils/api')
// 顶部安全区:实测右上角胶囊按钮矩形,避免品牌栏「消息」入口被胶囊压住(见 utils/nav-metrics.js) // 顶部安全区:实测右上角胶囊按钮矩形,避免品牌栏入口被胶囊压住(见 utils/nav-metrics.js)
const { navSafeData } = require('../../utils/nav-metrics') const { navSafeData } = require('../../utils/nav-metrics')
// 4×2 宫格的视觉映射(tone → index.wxss 的 .tone-* 渐变砖;subtitle → 主标题下的说明行)。 // 4×2 宫格的视觉映射(tone → index.wxss 的 .tone-* 渐变砖;subtitle → 主标题下的说明行)。
@@ -244,10 +244,7 @@ Page({
const result = await matchStore.like(userId) const result = await matchStore.like(userId)
this.setData({ recommendations: this.data.recommendations.filter((item) => item.userId !== userId) }) this.setData({ recommendations: this.data.recommendations.filter((item) => item.userId !== userId) })
if (result?.mutualMatch) { if (result?.mutualMatch) {
appStore.toastSuccess('双向喜欢!可以开始聊天了') appStore.toastSuccess('双向意向匹配成功,可查看公益服务')
if (result.matchedConversationId) {
wx.navigateTo({ url: `/pages/chat/chat?id=${result.matchedConversationId}` })
}
} else { } else {
appStore.toastSuccess('已表达喜欢') appStore.toastSuccess('已表达喜欢')
} }
@@ -327,7 +324,7 @@ Page({
wx.navigateTo({ url: '/pages/anti-fraud/anti-fraud' }) wx.navigateTo({ url: '/pages/anti-fraud/anti-fraud' })
}, },
onArticleTap(e) { wx.navigateTo({ url: `/pages/content-detail/content-detail?id=${e.currentTarget.dataset.id}` }) }, onArticleTap(e) { wx.navigateTo({ url: `/pages/content-detail/content-detail?id=${e.currentTarget.dataset.id}` }) },
onMessages() { wx.switchTab({ url: '/pages/messages/messages' }) }, onMessages() { wx.navigateTo({ url: '/pages/notifications/notifications' }) },
formatDate(timestamp) { formatDate(timestamp) {
if (!timestamp) return '' if (!timestamp) return ''
+1 -1
View File
@@ -9,7 +9,7 @@
</view> </view>
</view> </view>
<view class="brand-actions" bindtap="onMessages"> <view class="brand-actions" bindtap="onMessages">
<text class="message-label">消息</text> <text class="message-label">通知</text>
<text wx:if="{{unreadCount}}" class="badge">{{unreadCount}}</text> <text wx:if="{{unreadCount}}" class="badge">{{unreadCount}}</text>
</view> </view>
</view> </view>
@@ -47,8 +47,8 @@ Page({
try { try {
const result = action === 'like' ? await matchStore.like(userId) : await matchStore.pass(userId); const result = action === 'like' ? await matchStore.like(userId) : await matchStore.pass(userId);
this.setData({ recommendations: this.data.recommendations.filter((item) => item.userId !== userId) }); this.setData({ recommendations: this.data.recommendations.filter((item) => item.userId !== userId) });
if (action === 'like' && result && result.mutualMatch && result.matchedConversationId) { if (action === 'like' && result && result.mutualMatch) {
wx.navigateTo({ url: to.chat(result.matchedConversationId) }); appStore.toastSuccess('双向意向匹配成功,可查看公益服务');
} else { } else {
appStore.toastSuccess(action === 'like' ? '已表达喜欢' : '已暂不考虑'); appStore.toastSuccess(action === 'like' ? '已表达喜欢' : '已暂不考虑');
} }
+1 -1
View File
@@ -119,7 +119,7 @@ Page({
// ============ 匹配 ============ // ============ 匹配 ============
onGoMessages() { wx.switchTab({ url: ROUTE.TAB.MESSAGES }); }, onGoMessages() { wx.navigateTo({ url: ROUTE.SETTINGS.NOTIFICATIONS }); },
onGoLikers() { wx.navigateTo({ url: ROUTE.DETAIL.LIKERS }); }, onGoLikers() { wx.navigateTo({ url: ROUTE.DETAIL.LIKERS }); },
// ============ 账户与安全 ============ // ============ 账户与安全 ============
+1 -17
View File
@@ -163,10 +163,7 @@ Page({
relationStatus: RELATION_STATUS.MUTUAL_MATCH, relationStatus: RELATION_STATUS.MUTUAL_MATCH,
actionLoading: false, actionLoading: false,
}); });
appStore.toastSuccess('双向喜欢!可以开始聊天了'); appStore.toastSuccess('双向意向匹配成功,可查看公益服务');
if (result.matchedConversationId) {
wx.navigateTo({ url: to.chat(result.matchedConversationId) });
}
} else { } else {
this.setData({ this.setData({
isLiked: true, isLiked: true,
@@ -185,19 +182,6 @@ Page({
} }
}, },
/**
* 开始聊天(仅 Mutual 后可见,AGENTS 第 3 条)
*/
onGoChat() {
const { userId, canChat, profile } = this.data;
if (!canChat) {
appStore.toast('双方互相喜欢后才能开始聊天');
return;
}
// matchedConversationId 优先来自最近 like 结果;无则用 userId 占位由 chat 页拉取
const conversationId = (profile && profile.matchedConversationId) || userId;
wx.navigateTo({ url: to.chat(conversationId) });
},
/** /**
* 举报:跳转 report 页,不在本页处理(AGENTS 第 7 条:举报内容不在此暴露) * 举报:跳转 report 页,不在本页处理(AGENTS 第 7 条:举报内容不在此暴露)
@@ -113,14 +113,6 @@
bind:tap="onReport" bind:tap="onReport"
disabled="{{actionLoading}}" disabled="{{actionLoading}}"
>举报</ui-button> >举报</ui-button>
<ui-button
wx:if="{{canChat}}"
variant="secondary"
size="md"
custom-class="profile-actions__btn"
bind:tap="onGoChat"
disabled="{{actionLoading}}"
>开始聊天</ui-button>
<ui-button <ui-button
wx:if="{{!isLiked}}" wx:if="{{!isLiked}}"
variant="primary" variant="primary"
+1
View File
@@ -14,6 +14,7 @@ const profileService = require('./profileService');
const verificationService = require('./verificationService'); const verificationService = require('./verificationService');
const recommendationService = require('./recommendationService'); const recommendationService = require('./recommendationService');
const matchService = require('./matchService'); const matchService = require('./matchService');
// 兼容未注册的历史聊天页面源码;用户端不再暴露该服务。
const messageService = require('./messageService'); const messageService = require('./messageService');
const activityService = require('./activityService'); const activityService = require('./activityService');
const matchmakerService = require('./matchmakerService'); const matchmakerService = require('./matchmakerService');
+1 -2
View File
@@ -20,8 +20,7 @@ const api = require('../utils/api');
* 表达喜欢 * 表达喜欢
* POST /likes { targetUserId } * POST /likes { targetUserId }
* *
* 响应对齐 types.LikeResult:{ relationStatus, mutualMatch, matchedConversationId }。 * 响应对齐 types.LikeResult:{ relationStatus, mutualMatch }。
* mutualMatch=true 且 matchedConversationId 非空时,调用方可直接跳转聊天页。
* @param {string} userId * @param {string} userId
* @returns {Promise<import('../types').LikeResult>} * @returns {Promise<import('../types').LikeResult>}
*/ */
+5 -19
View File
@@ -15,6 +15,7 @@
const authStore = require('./authStore'); const authStore = require('./authStore');
const userStore = require('./userStore'); const userStore = require('./userStore');
const matchStore = require('./matchStore'); const matchStore = require('./matchStore');
// 兼容未注册的历史聊天页面源码;页面与服务端入口均已下线,不在启动流程中初始化。
const messageStore = require('./messageStore'); const messageStore = require('./messageStore');
const activityStore = require('./activityStore'); const activityStore = require('./activityStore');
const matchmakerStore = require('./matchmakerStore'); const matchmakerStore = require('./matchmakerStore');
@@ -128,53 +129,40 @@ function isProfileApproved() {
// ============ 跨 Store 编排 ============ // ============ 跨 Store 编排 ============
/** /**
* 拉黑用户:触发 MatchStore 与 MessageStore 同步副作用 * 拉黑用户:从推荐/Mutual 列表移除该用户。
* - MatchStore:从推荐/Mutual 列表移除该用户
* - MessageStore:标记对应会话不可发消息(AGENTS 第 4 条)
* @param {string} userId * @param {string} userId
* @param {string} [reason] * @param {string} [reason]
*/ */
async function blockUser(userId, reason) { async function blockUser(userId, reason) {
await matchStore.block(userId, reason); await matchStore.block(userId, reason);
// 查找对应会话并标记停用
const conv = messageStore.state.conversations.find(
(c) => c.targetUserId === userId || c.conversationId === userId,
);
if (conv) messageStore.markConversationBlocked(conv.conversationId);
} }
/** /**
* Like 用户:若返回 mutualMatch=true,自动刷新会话列表 * Like 用户:若返回 mutualMatch=true,仅保留匹配结果和公益服务引导。
* @param {string} userId * @param {string} userId
* @returns {Promise<import('../types').LikeResult>} * @returns {Promise<import('../types').LikeResult>}
*/ */
async function likeUser(userId) { async function likeUser(userId) {
const result = await matchStore.like(userId); const result = await matchStore.like(userId);
if (result && result.mutualMatch) {
// Mutual Match 达成,刷新会话列表,让用户能立即开始聊天
await messageStore.fetchConversations();
}
return result; return result;
} }
/** /**
* 解除匹配:触发 MatchStore 与 MessageStore 同步副作用 * 解除匹配。
* @param {string} matchId * @param {string} matchId
*/ */
async function unmatchConversation(matchId) { async function unmatchConversation(matchId) {
await matchStore.unmatch(matchId); await matchStore.unmatch(matchId);
messageStore.markConversationBlocked(matchId);
} }
/** /**
* 成功牵手:双确认第二步(仅被请求方调用),成功后刷新 userStore 与会话列表 * 成功牵手:双确认第二步(仅被请求方调用),成功后刷新 userStore。
* @param {string} matchId * @param {string} matchId
*/ */
async function confirmSuccess(matchId) { async function confirmSuccess(matchId) {
const result = await matchStore.confirmSuccess(matchId); const result = await matchStore.confirmSuccess(matchId);
// 成功牵手后关系状态变化,需刷新 userState // 成功牵手后关系状态变化,需刷新 userState
try { await userStore.fetchStatus(); } catch (e) { /* 不阻塞主流程 */ } try { await userStore.fetchStatus(); } catch (e) { /* 不阻塞主流程 */ }
try { await messageStore.fetchConversations(); } catch (e) { /* 同上 */ }
return result; return result;
} }
@@ -185,14 +173,12 @@ async function confirmSuccess(matchId) {
* 1. appStore.init() — 拉取服务端配置(不阻塞核心) * 1. appStore.init() — 拉取服务端配置(不阻塞核心)
* 2. authStore.hydrate() — 从本地恢复登录态 * 2. authStore.hydrate() — 从本地恢复登录态
* 3. userStore.hydrate() — 从本地恢复用户状态快照 * 3. userStore.hydrate() — 从本地恢复用户状态快照
* 4. messageStore.hydrate() — 从本地恢复未读数(兜底)
* 5. 若已登录,异步刷新 userStore.fetchStatus()(不阻塞启动) * 5. 若已登录,异步刷新 userStore.fetchStatus()(不阻塞启动)
*/ */
async function bootstrap() { async function bootstrap() {
// 同步阶段:本地状态恢复(避免白屏) // 同步阶段:本地状态恢复(避免白屏)
authStore.hydrate(); authStore.hydrate();
userStore.hydrate(); userStore.hydrate();
messageStore.hydrate();
// 异步阶段:服务端配置 + 用户状态刷新 // 异步阶段:服务端配置 + 用户状态刷新
// 配置失败不阻塞启动;状态失败由路由守卫处理(跳登录页) // 配置失败不阻塞启动;状态失败由路由守卫处理(跳登录页)
+1 -1
View File
@@ -163,7 +163,7 @@ class MatchStore extends BaseStore {
/** /**
* 拉黑用户(Block 优先级最高,AGENTS 第 4 条) * 拉黑用户(Block 优先级最高,AGENTS 第 4 条)
* 拉黑后:本地从推荐/Mutual 列表移除;调用方应跳离当前用户详情页。 * 拉黑后:本地从推荐/Mutual 列表移除;调用方应跳离当前用户详情页。
* 跨 Store 副作用由 stores/index.js 编排:通知 messageStore 标记会话不可发消息。 * 匹配结果由 stores/index.js 编排,不创建用户间聊天会话。
*/ */
async block(userId, reason) { async block(userId, reason) {
if (this._actionLocks.has(`block:${userId}`)) return; if (this._actionLocks.has(`block:${userId}`)) return;
-1
View File
@@ -109,7 +109,6 @@ export interface Recommendation extends User {
export interface LikeResult { export interface LikeResult {
relationStatus: RelationStatus; relationStatus: RelationStatus;
mutualMatch: boolean; mutualMatch: boolean;
matchedConversationId?: string;
} }
// ============ Conversation / Message(规范 §35-38, §61-63) ============ // ============ Conversation / Message(规范 §35-38, §61-63) ============
-1
View File
@@ -239,7 +239,6 @@ export interface LikeResult {
mutual: boolean mutual: boolean
mutualMatch?: boolean mutualMatch?: boolean
matchCreated?: boolean matchCreated?: boolean
matchedConversationId?: string
} }
// ===== 通知 ===== // ===== 通知 =====
+28
View File
@@ -0,0 +1,28 @@
import json
from pathlib import Path
ROOT = Path(__file__).parents[2]
def test_miniapp_has_no_user_chat_pages_or_message_tab():
app = json.loads((ROOT / "miniapp" / "app.json").read_text(encoding="utf-8"))
assert not any(page in {"pages/messages/messages", "pages/chat/chat"} for page in app["pages"])
assert not any(item.get("pagePath") == "pages/messages/messages" for item in app["tabBar"]["list"])
def test_match_success_does_not_create_or_return_conversation():
like = (ROOT / "backend/src/main/java/com/danyang/match/platform/match/controller/LikeController.java").read_text(encoding="utf-8")
relationship = (ROOT / "backend/src/main/java/com/danyang/match/platform/relationship/service/RelationshipService.java").read_text(encoding="utf-8")
assert "matchedConversationId" not in like
assert "getOrCreateConversation" not in like
assert "conversationAutoCreated" not in relationship
def test_chat_transport_is_disabled_at_backend_boundary():
guard = ROOT / "backend/src/main/java/com/danyang/match/platform/chat/config/ChatDisabledFilter.java"
assert guard.exists()
source = guard.read_text(encoding="utf-8")
assert "/conversations" in source
assert "/ws/chat" in source
assert "CHAT_DISABLED" in source