diff --git a/backend/src/main/java/com/danyang/match/platform/capability/controller/CapabilityController.java b/backend/src/main/java/com/danyang/match/platform/capability/controller/CapabilityController.java new file mode 100644 index 0000000..7f48fdb --- /dev/null +++ b/backend/src/main/java/com/danyang/match/platform/capability/controller/CapabilityController.java @@ -0,0 +1,37 @@ +package com.danyang.match.platform.capability.controller; + +import com.danyang.match.platform.capability.service.CapabilityService; +import com.danyang.match.platform.home.dto.HomeDTO; +import com.danyang.match.platform.shared.dto.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.danyang.match.platform.shared.security.principal.UserPrincipal; + +/** + * 用户能力开关端点 + * GET /api/v1/capabilities + * + * 前端各功能入口的 enabled/disabledReason 由本接口返回决定, + * 不自行拼状态判断(AGENTS.md 第 26 条)。 + */ +@RestController +@RequestMapping("/capabilities") +@RequiredArgsConstructor +public class CapabilityController { + + private final CapabilityService capabilityService; + + @GetMapping + public ApiResponse getCapabilities( + @AuthenticationPrincipal UserPrincipal principal) { + String userId = principal == null ? null : principal.id(); + if (userId == null) { + return ApiResponse.ok(new HomeDTO.Capabilities()); + } + return ApiResponse.ok(capabilityService.getCapabilities(userId)); + } +} diff --git a/backend/src/main/java/com/danyang/match/platform/capability/service/CapabilityService.java b/backend/src/main/java/com/danyang/match/platform/capability/service/CapabilityService.java new file mode 100644 index 0000000..41ac7d5 --- /dev/null +++ b/backend/src/main/java/com/danyang/match/platform/capability/service/CapabilityService.java @@ -0,0 +1,113 @@ +package com.danyang.match.platform.capability.service; + +import com.danyang.match.platform.home.dto.HomeDTO; +import com.danyang.match.platform.local_eligibility.mapper.LocalEligibilityMapper; +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 com.danyang.match.platform.verification.mapper.UserIdentityMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.Optional; + +/** + * 用户能力判断服务 — 前端 Capability 控制入口 + * + * AGENTS.md 第 26 条: + * 前端功能入口可见性由服务端 CapabilityService 返回决定, + * 前端不自行拼状态判断。 + * + * 不可违反的业务规则: + * - 能力判断仅基于账号状态、实名认证、资料审核、本地资格 + * - 不复制业务逻辑(Required 条件、Mutual Match 状态由各业务模块自己判断) + */ +@Service +@RequiredArgsConstructor +public class CapabilityService { + + private final UserMapper userMapper; + private final UserProfileMapper userProfileMapper; + private final UserIdentityMapper userIdentityMapper; + private final LocalEligibilityMapper localEligibilityMapper; + + /** + * 获取用户的能力开关集合 + * GET /api/v1/capabilities 直接返回,首页聚合也复用这里 + */ + public HomeDTO.Capabilities getCapabilities(String userId) { + User user = userMapper.findById(userId).orElse(null); + if (user == null) { + return new HomeDTO.Capabilities(); + } + + boolean baseEligible = Constants.ACCOUNT_STATUS_NORMAL.equals(user.getAccountStatus()) + && isIdentityVerified(userId) + && isProfileApproved(user); + + HomeDTO.Capabilities caps = new HomeDTO.Capabilities(); + caps.setCanReceiveRecommendations(baseEligible + && isSearching(user) + && Boolean.TRUE.equals(user.getRecommendationEnabled())); + caps.setCanSearchUsers(baseEligible); + caps.setCanLike(baseEligible); + caps.setCanChat(false); // 由 Match 状态决定,首页层暂不展示 + caps.setCanApplyMatchmakingActivity(baseEligible); + caps.setCanApplyCounseling(baseEligible); + caps.setCanApplyMatchmaker(baseEligible); + return caps; + } + + // ==================== 公共判断方法(供各业务模块复用) ==================== + + /** 账号是否正常 */ + public boolean isAccountNormal(User user) { + return user != null && Constants.ACCOUNT_STATUS_NORMAL.equals(user.getAccountStatus()); + } + + /** 是否已实名认证 */ + public boolean isIdentityVerified(String userId) { + return userIdentityMapper.findByUserId(userId) + .map(identity -> Constants.VERIFICATION_STATUS_VERIFIED.equals(identity.getVerificationStatus())) + .orElse(false); + } + + /** 资料是否审核通过 */ + public boolean isProfileApproved(User user) { + if (user == null) return false; + String status = userProfileMapper.findByUserId(user.getId()) + .map(UserProfile::getProfileStatus).orElse(null); + return Constants.PROFILE_STATUS_APPROVED.equals(status) + || Constants.PROFILE_STATUS_COMPLETED.equals(status); + } + + /** 是否在认真寻找中 */ + public boolean isSearching(User user) { + return user != null && Constants.RELATIONSHIP_STATUS_SEARCHING.equals(user.getRelationshipStatus()); + } + + /** 是否基础资格满足(正常账号 + 实名 + 资料通过) */ + public boolean isBaseEligible(User user, String userId) { + return isAccountNormal(user) && isIdentityVerified(userId) && isProfileApproved(user); + } + + /** 是否有本地资格(丹阳户籍/工作/居住) */ + public boolean hasLocalEligibility(String userId) { + return localEligibilityMapper.findByUserId(userId).isPresent(); + } + + /** 根据能力生成 QuickEntry 的 enabled/disabledReason */ + public HomeDTO.QuickEntry entry(String code, String label, String icon, + boolean enabled, String targetPage) { + HomeDTO.QuickEntry e = new HomeDTO.QuickEntry(); + e.setCode(code); + e.setLabel(label); + e.setIcon(icon); + e.setEnabled(enabled); + e.setDisabledReason(enabled ? null : "完成实名认证和资料审核后可使用"); + e.setTargetPage(targetPage); + return e; + } +} diff --git a/backend/src/main/java/com/danyang/match/platform/home/service/HomeApplicationService.java b/backend/src/main/java/com/danyang/match/platform/home/service/HomeApplicationService.java index 6dd4179..288b4c1 100644 --- a/backend/src/main/java/com/danyang/match/platform/home/service/HomeApplicationService.java +++ b/backend/src/main/java/com/danyang/match/platform/home/service/HomeApplicationService.java @@ -5,12 +5,10 @@ import com.danyang.match.platform.banner.mapper.HomeBannerMapper; import com.danyang.match.platform.banner.service.HomeBannerService; import com.danyang.match.platform.activity.entity.Activity; import com.danyang.match.platform.activity.mapper.ActivityMapper; +import com.danyang.match.platform.capability.service.CapabilityService; import com.danyang.match.platform.content.mapper.ContentArticleMapper; import com.danyang.match.platform.service_appointment.entity.ServiceLocation; import com.danyang.match.platform.service_appointment.mapper.ServiceLocationMapper; -import com.danyang.match.platform.verification.mapper.UserIdentityMapper; -import com.danyang.match.platform.local_eligibility.entity.LocalEligibility; -import com.danyang.match.platform.local_eligibility.mapper.LocalEligibilityMapper; import com.danyang.match.platform.match.service.RecommendationEngineService; import com.danyang.match.platform.profile.entity.UserProfile; import com.danyang.match.platform.profile.mapper.UserProfileMapper; @@ -32,6 +30,7 @@ import java.util.stream.Collectors; * 首页聚合服务 * 对应 GET /api/v1/home * 不复制业务规则,仅聚合各模块数据 + * 能力判断已委托给 CapabilityService(AGENTS.md 第 26 条) */ @Service @RequiredArgsConstructor @@ -48,7 +47,7 @@ public class HomeApplicationService { private final ActivityMapper activityMapper; private final ContentArticleMapper contentArticleMapper; private final ServiceLocationMapper serviceLocationMapper; - private final UserIdentityMapper userIdentityMapper; + private final CapabilityService capabilityService; /** * 获取首页聚合数据 @@ -143,25 +142,7 @@ public class HomeApplicationService { } private HomeDTO.Capabilities buildCapabilities(String userId) { - User user = userMapper.findById(userId).orElse(null); - if (user == null) { - return new HomeDTO.Capabilities(); - } - - boolean baseEligible = Constants.ACCOUNT_STATUS_NORMAL.equals(user.getAccountStatus()) - && isIdentityVerified(userId) - && isProfileApproved(user); - HomeDTO.Capabilities caps = new HomeDTO.Capabilities(); - caps.setCanReceiveRecommendations(baseEligible - && isSearching(user) - && Boolean.TRUE.equals(user.getRecommendationEnabled())); - caps.setCanSearchUsers(baseEligible); - caps.setCanLike(baseEligible); - caps.setCanChat(false); // 由Match状态决定,首页层暂不展示 - caps.setCanApplyMatchmakingActivity(baseEligible); - caps.setCanApplyCounseling(baseEligible); - caps.setCanApplyMatchmaker(baseEligible); - return caps; + return capabilityService.getCapabilities(userId); } private List buildBannerItems(String userStatus) { @@ -185,15 +166,16 @@ public class HomeApplicationService { private List buildQuickEntries(HomeDTO.Capabilities caps) { // 固定8个入口,按V1.1规范顺序 List entries = new ArrayList<>(); + CapabilityService capsSvc = this.capabilityService; - entries.add(entry("find_object", "找对象", "icon-dating", caps.isCanSearchUsers(), "/pages/discover/discover")); - entries.add(entry("matchmaker", "公益红娘", "icon-matchmaker", caps.isCanApplyMatchmaker(), "/pages/matchmaker/matchmaker")); - entries.add(entry("activities", "公益活动", "icon-activity", true, "/pages/activity/activity")); - entries.add(entry("counseling", "婚恋咨询", "icon-counseling", caps.isCanApplyCounseling(), "/pages/counseling-intro/counseling-intro")); - entries.add(entry("classroom", "婚恋课堂", "icon-class", true, "/pages/content/content")); - entries.add(entry("premarital", "婚前辅导", "icon-premarital", caps.isCanApplyCounseling(), "/pages/premarital-intro/premarital-intro")); - entries.add(entry("anti_fraud", "婚恋反诈", "icon-shield", true, "/pages/anti-fraud/anti-fraud")); - entries.add(entry("service_station", "善缘服务站", "icon-location", true, "/pages/service-station/service-station")); + entries.add(capsSvc.entry("find_object", "找对象", "icon-dating", caps.isCanSearchUsers(), "/pages/discover/discover")); + entries.add(capsSvc.entry("matchmaker", "公益红娘", "icon-matchmaker", caps.isCanApplyMatchmaker(), "/pages/matchmaker/matchmaker")); + entries.add(capsSvc.entry("activities", "公益活动", "icon-activity", true, "/pages/activity/activity")); + entries.add(capsSvc.entry("counseling", "婚恋咨询", "icon-counseling", caps.isCanApplyCounseling(), "/pages/counseling-intro/counseling-intro")); + entries.add(capsSvc.entry("classroom", "婚恋课堂", "icon-class", true, "/pages/content/content")); + entries.add(capsSvc.entry("premarital", "婚前辅导", "icon-premarital", caps.isCanApplyCounseling(), "/pages/premarital-intro/premarital-intro")); + entries.add(capsSvc.entry("anti_fraud", "婚恋反诈", "icon-shield", true, "/pages/anti-fraud/anti-fraud")); + entries.add(capsSvc.entry("service_station", "善缘服务站", "icon-location", true, "/pages/service-station/service-station")); return entries; } @@ -202,10 +184,10 @@ public class HomeApplicationService { if (user == null || !Boolean.TRUE.equals(user.getRecommendationEnabled())) { return List.of(); } - if (!isSearching(user)) { + if (!capabilityService.isSearching(user)) { return List.of(); } - if (!isProfileApproved(user)) { + if (!capabilityService.isProfileApproved(user)) { return List.of(); } return recommendationEngineService.getDailyRecommendations(userId).stream() @@ -298,34 +280,6 @@ public class HomeApplicationService { // ==================== 私有工具方法 ==================== - private boolean isSearching(User user) { - return Constants.RELATIONSHIP_STATUS_SEARCHING.equals(user.getRelationshipStatus()); - } - - private boolean isIdentityVerified(String userId) { - return userIdentityMapper.findByUserId(userId) - .map(identity -> Constants.VERIFICATION_STATUS_VERIFIED.equals(identity.getVerificationStatus())) - .orElse(false); - } - - private boolean isProfileApproved(User user) { - String status = userProfileMapper.findByUserId(user.getId()) - .map(UserProfile::getProfileStatus).orElse(null); - return Constants.PROFILE_STATUS_APPROVED.equals(status) - || Constants.PROFILE_STATUS_COMPLETED.equals(status); - } - - private HomeDTO.QuickEntry entry(String code, String label, String icon, boolean enabled, String targetPage) { - HomeDTO.QuickEntry e = new HomeDTO.QuickEntry(); - e.setCode(code); - e.setLabel(label); - e.setIcon(icon); - e.setEnabled(enabled); - e.setDisabledReason(enabled ? null : "完成实名认证和资料审核后可使用"); - e.setTargetPage(targetPage); - return e; - } - private HomeDTO.ServiceRecommendation serviceRec(String type, String title, String desc, String cta, String target) { HomeDTO.ServiceRecommendation sr = new HomeDTO.ServiceRecommendation(); sr.setType(type); diff --git a/ui-tests/phase07-home-contracts.mjs b/ui-tests/phase07-home-contracts.mjs index c68729c..329dc42 100644 --- a/ui-tests/phase07-home-contracts.mjs +++ b/ui-tests/phase07-home-contracts.mjs @@ -3,6 +3,9 @@ import fs from 'node:fs' const read = (path) => fs.readFileSync(path, 'utf8') const home = read('backend/src/main/java/com/danyang/match/platform/home/service/HomeApplicationService.java') +const caps = fs.existsSync('backend/src/main/java/com/danyang/match/platform/capability/service/CapabilityService.java') + ? read('backend/src/main/java/com/danyang/match/platform/capability/service/CapabilityService.java') + : home // fallback: CapabilityService 独立化前逻辑在 HomeApplicationService 里 const miniapp = read('miniapp/pages/index/index.ts') const wxml = read('miniapp/pages/index/index.wxml') const project = JSON.parse(read('project.config.json')) @@ -19,9 +22,9 @@ assert.match(home, /userIdentityMapper\.findByUserId/, '首页实名认证状态 assert.match(home, /recommendationEngineService\.getDailyRecommendations/, '首页推荐必须来自正式推荐引擎') assert.match(home, /contentArticleMapper\.findPublishedAll/, '首页课堂内容必须来自内容模块') assert.match(home, /serviceLocationMapper\.selectOne/, '首页服务站必须来自服务站模块') -assert.match(home, /setDisabledReason/, '不可用入口必须返回服务端原因') -assert.match(home, /isIdentityVerified/, 'Capability 必须校验真实实名状态') -assert.match(home, /ACCOUNT_STATUS_NORMAL/, 'Capability 必须拒绝非正常账户') +assert.match(caps, /setDisabledReason/, '不可用入口必须返回服务端原因') +assert.match(caps, /isIdentityVerified/, 'Capability 必须校验真实实名状态') +assert.match(caps, /ACCOUNT_STATUS_NORMAL/, 'Capability 必须拒绝非正常账户') assert.match(home, /\/pages\/counseling-intro\/counseling-intro/, '咨询服务建议必须跳转真实页面') assert.match(home, /\/pages\/premarital-intro\/premarital-intro/, '婚前辅导建议必须跳转真实页面') assert.doesNotMatch(home, /"pages\//, '服务端返回的小程序路由必须以斜杠开头')