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.banner.service.HomeBannerService;
import com.danyang.match.platform.activity.entity.Activity; import com.danyang.match.platform.activity.entity.Activity;
import com.danyang.match.platform.activity.mapper.ActivityMapper; 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.content.mapper.ContentArticleMapper;
import com.danyang.match.platform.service_appointment.entity.ServiceLocation; import com.danyang.match.platform.service_appointment.entity.ServiceLocation;
import com.danyang.match.platform.service_appointment.mapper.ServiceLocationMapper; 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.match.service.RecommendationEngineService;
import com.danyang.match.platform.profile.entity.UserProfile; import com.danyang.match.platform.profile.entity.UserProfile;
import com.danyang.match.platform.profile.mapper.UserProfileMapper; import com.danyang.match.platform.profile.mapper.UserProfileMapper;
@@ -32,6 +30,7 @@ import java.util.stream.Collectors;
* 首页聚合服务 * 首页聚合服务
* 对应 GET /api/v1/home * 对应 GET /api/v1/home
* 不复制业务规则,仅聚合各模块数据 * 不复制业务规则,仅聚合各模块数据
* 能力判断已委托给 CapabilityService(AGENTS.md 第 26 条)
*/ */
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
@@ -48,7 +47,7 @@ public class HomeApplicationService {
private final ActivityMapper activityMapper; private final ActivityMapper activityMapper;
private final ContentArticleMapper contentArticleMapper; private final ContentArticleMapper contentArticleMapper;
private final ServiceLocationMapper serviceLocationMapper; 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) { private HomeDTO.Capabilities buildCapabilities(String userId) {
User user = userMapper.findById(userId).orElse(null); return capabilityService.getCapabilities(userId);
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;
} }
private List<HomeDTO.BannerItem> buildBannerItems(String userStatus) { private List<HomeDTO.BannerItem> buildBannerItems(String userStatus) {
@@ -185,15 +166,16 @@ public class HomeApplicationService {
private List<HomeDTO.QuickEntry> buildQuickEntries(HomeDTO.Capabilities caps) { private List<HomeDTO.QuickEntry> buildQuickEntries(HomeDTO.Capabilities caps) {
// 固定8个入口,按V1.1规范顺序 // 固定8个入口,按V1.1规范顺序
List<HomeDTO.QuickEntry> entries = new ArrayList<>(); List<HomeDTO.QuickEntry> entries = new ArrayList<>();
CapabilityService capsSvc = this.capabilityService;
entries.add(entry("find_object", "找对象", "icon-dating", caps.isCanSearchUsers(), "/pages/discover/discover")); entries.add(capsSvc.entry("find_object", "找对象", "icon-dating", caps.isCanSearchUsers(), "/pages/discover/discover"));
entries.add(entry("matchmaker", "公益红娘", "icon-matchmaker", caps.isCanApplyMatchmaker(), "/pages/matchmaker/matchmaker")); entries.add(capsSvc.entry("matchmaker", "公益红娘", "icon-matchmaker", caps.isCanApplyMatchmaker(), "/pages/matchmaker/matchmaker"));
entries.add(entry("activities", "公益活动", "icon-activity", true, "/pages/activity/activity")); entries.add(capsSvc.entry("activities", "公益活动", "icon-activity", true, "/pages/activity/activity"));
entries.add(entry("counseling", "婚恋咨询", "icon-counseling", caps.isCanApplyCounseling(), "/pages/counseling-intro/counseling-intro")); entries.add(capsSvc.entry("counseling", "婚恋咨询", "icon-counseling", caps.isCanApplyCounseling(), "/pages/counseling-intro/counseling-intro"));
entries.add(entry("classroom", "婚恋课堂", "icon-class", true, "/pages/content/content")); entries.add(capsSvc.entry("classroom", "婚恋课堂", "icon-class", true, "/pages/content/content"));
entries.add(entry("premarital", "婚前辅导", "icon-premarital", caps.isCanApplyCounseling(), "/pages/premarital-intro/premarital-intro")); entries.add(capsSvc.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(capsSvc.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("service_station", "善缘服务站", "icon-location", true, "/pages/service-station/service-station"));
return entries; return entries;
} }
@@ -202,10 +184,10 @@ public class HomeApplicationService {
if (user == null || !Boolean.TRUE.equals(user.getRecommendationEnabled())) { if (user == null || !Boolean.TRUE.equals(user.getRecommendationEnabled())) {
return List.of(); return List.of();
} }
if (!isSearching(user)) { if (!capabilityService.isSearching(user)) {
return List.of(); return List.of();
} }
if (!isProfileApproved(user)) { if (!capabilityService.isProfileApproved(user)) {
return List.of(); return List.of();
} }
return recommendationEngineService.getDailyRecommendations(userId).stream() 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) { private HomeDTO.ServiceRecommendation serviceRec(String type, String title, String desc, String cta, String target) {
HomeDTO.ServiceRecommendation sr = new HomeDTO.ServiceRecommendation(); HomeDTO.ServiceRecommendation sr = new HomeDTO.ServiceRecommendation();
sr.setType(type); sr.setType(type);
+6 -3
View File
@@ -3,6 +3,9 @@ import fs from 'node:fs'
const read = (path) => fs.readFileSync(path, 'utf8') const read = (path) => fs.readFileSync(path, 'utf8')
const home = read('backend/src/main/java/com/danyang/match/platform/home/service/HomeApplicationService.java') 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 miniapp = read('miniapp/pages/index/index.ts')
const wxml = read('miniapp/pages/index/index.wxml') const wxml = read('miniapp/pages/index/index.wxml')
const project = JSON.parse(read('project.config.json')) 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, /recommendationEngineService\.getDailyRecommendations/, '首页推荐必须来自正式推荐引擎')
assert.match(home, /contentArticleMapper\.findPublishedAll/, '首页课堂内容必须来自内容模块') assert.match(home, /contentArticleMapper\.findPublishedAll/, '首页课堂内容必须来自内容模块')
assert.match(home, /serviceLocationMapper\.selectOne/, '首页服务站必须来自服务站模块') assert.match(home, /serviceLocationMapper\.selectOne/, '首页服务站必须来自服务站模块')
assert.match(home, /setDisabledReason/, '不可用入口必须返回服务端原因') assert.match(caps, /setDisabledReason/, '不可用入口必须返回服务端原因')
assert.match(home, /isIdentityVerified/, 'Capability 必须校验真实实名状态') assert.match(caps, /isIdentityVerified/, 'Capability 必须校验真实实名状态')
assert.match(home, /ACCOUNT_STATUS_NORMAL/, 'Capability 必须拒绝非正常账户') assert.match(caps, /ACCOUNT_STATUS_NORMAL/, 'Capability 必须拒绝非正常账户')
assert.match(home, /\/pages\/counseling-intro\/counseling-intro/, '咨询服务建议必须跳转真实页面') assert.match(home, /\/pages\/counseling-intro\/counseling-intro/, '咨询服务建议必须跳转真实页面')
assert.match(home, /\/pages\/premarital-intro\/premarital-intro/, '婚前辅导建议必须跳转真实页面') assert.match(home, /\/pages\/premarital-intro\/premarital-intro/, '婚前辅导建议必须跳转真实页面')
assert.doesNotMatch(home, /"pages\//, '服务端返回的小程序路由必须以斜杠开头') assert.doesNotMatch(home, /"pages\//, '服务端返回的小程序路由必须以斜杠开头')