feat(capability): extract CapabilityService — AGENTS.md rule #26 compliance

New module backend/capability/:
- CapabilityService: getCapabilities(entry point for frontend),
  plus public helpers (isAccountNormal/isIdentityVerified/isProfileApproved/
  isSearching/isBaseEligible/hasLocalEligibility/entry builder)
- CapabilityController: GET /api/v1/capabilities endpoint

HomeApplicationService refactored:
- Injects CapabilityService, delegates buildCapabilities()
  and QuickEntry construction + recommendation eligibility checks
- Removes duplicated private methods (isSearching/isIdentityVerified/
  isProfileApproved/entry) — single source of truth now in CapabilityService

Contracts updated to check CapabilityService.java for capability-related
assertions (setDisabledReason/isIdentityVerified/ACCOUNT_STATUS_NORMAL)
This commit is contained in:
Codex
2026-09-14 11:38:30 +08:00
parent 22325ec2b0
commit dd58ab6eae
4 changed files with 171 additions and 64 deletions
@@ -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<HomeDTO.Capabilities> 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));
}
}
@@ -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;
}
}
@@ -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<HomeDTO.BannerItem> buildBannerItems(String userStatus) {
@@ -185,15 +166,16 @@ public class HomeApplicationService {
private List<HomeDTO.QuickEntry> buildQuickEntries(HomeDTO.Capabilities caps) {
// 固定8个入口,按V1.1规范顺序
List<HomeDTO.QuickEntry> 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);