feat: enhance home aggregate, review atomicity, local eligibility and match engine (phase 04-07)

This commit is contained in:
Codex
2026-09-14 10:02:07 +08:00
parent 10887f40ed
commit bb380d40f6
16 changed files with 409 additions and 104 deletions
+53 -4
View File
@@ -32,7 +32,7 @@
</el-table-column>
<el-table-column label="操作" width="120" fixed="right">
<template #default="{ row }">
<el-button v-if="canProcess" type="primary" link @click="handleClaim(row)">领取审核</el-button>
<el-button v-if="canClaim(row)" type="primary" link @click="handleClaim(row)">领取审核</el-button>
<span v-else class="muted">只读</span>
</template>
</el-table-column>
@@ -85,7 +85,24 @@
</el-descriptions>
<el-empty v-else description="暂无资料快照" />
<template v-if="canProcess">
<template v-if="currentTask.reviewType === 'local_eligibility'">
<h3 class="drawer-heading">本地资格佐证</h3>
<el-alert title="佐证属于敏感数据,仅在审核必要时查看;每次访问都会记录目的和结果。" type="warning" :closable="false" show-icon />
<el-table :data="currentTask.localEligibilityEvidences || []" class="evidence-table">
<el-table-column prop="evidenceType" label="佐证类型" min-width="140" />
<el-table-column prop="createdAt" label="上传时间" min-width="170">
<template #default="{ row }">{{ formatTime(row.createdAt) }}</template>
</el-table-column>
<el-table-column label="操作" width="100">
<template #default="{ row }">
<el-button v-if="canViewEvidence" link type="primary" @click="viewEvidence(row)">查看</el-button>
<span v-else class="muted">无权限</span>
</template>
</el-table-column>
</el-table>
</template>
<template v-if="canProcessCurrent">
<h3 class="drawer-heading">审核操作</h3>
<el-form label-position="top">
<el-form-item label="审核意见">
@@ -98,7 +115,7 @@
</el-form-item>
<div class="review-actions">
<el-button type="success" :loading="submitting" @click="runDecision('approve', '审核通过')">通过</el-button>
<el-button type="warning" :loading="submitting" @click="runDecision('need-edit', '退回修改')">需修改</el-button>
<el-button type="warning" :loading="submitting" @click="runDecision('need-edit', currentTask.reviewType === 'local_eligibility' ? '要求补充佐证' : '退回修改')">{{ currentTask.reviewType === 'local_eligibility' ? '补充佐证' : '需修改' }}</el-button>
<el-button type="danger" :loading="submitting" @click="runDecision('reject', '拒绝资料')">拒绝</el-button>
</div>
</el-form>
@@ -127,6 +144,16 @@ const currentTask = ref<any>(null)
const reasonOptions = ref<any[]>([])
const reviewForm = ref({ comment: '', reasonCodes: [] as string[] })
const canProcess = computed(() => authStore.permissions.includes('review.process'))
const canViewEvidence = computed(() => authStore.permissions.includes('local_eligibility.evidence.view'))
const canProcessCurrent = computed(() => currentTask.value?.reviewType === 'local_eligibility'
? authStore.permissions.includes('local_eligibility.review')
: canProcess.value)
function canClaim(task: any) {
return task.reviewType === 'local_eligibility'
? authStore.permissions.includes('local_eligibility.review')
: canProcess.value
}
const pendingTasks = computed(() => tasks.value.filter((task) => !task.assignedOperatorId))
const myTasks = computed(() => tasks.value.filter((task) => task.assignedOperatorId === authStore.operator?.operatorId))
@@ -196,7 +223,13 @@ async function runDecision(action: 'approve' | 'need-edit' | 'reject', label: st
}
submitting.value = true
try {
const response: any = await post(`/operator/reviews/${currentTask.value.taskId}/${action}`, {
const localActions = {
approve: 'approve-local-eligibility',
'need-edit': 'need-evidence',
reject: 'reject-local-eligibility',
}
const endpointAction = currentTask.value.reviewType === 'local_eligibility' ? localActions[action] : action
const response: any = await post(`/operator/reviews/${currentTask.value.taskId}/${endpointAction}`, {
comment: reviewForm.value.comment,
reasonCodes: reviewForm.value.reasonCodes,
})
@@ -211,6 +244,22 @@ async function runDecision(action: 'approve' | 'need-edit' | 'reject', label: st
}
}
async function viewEvidence(row: any) {
if (!currentTask.value) return
try {
const { value: purpose } = await ElMessageBox.prompt('请填写本次查看佐证的业务目的', '敏感数据访问确认', {
confirmButtonText: '记录并查看',
cancelButtonText: '取消',
inputPattern: /\S+/,
inputErrorMessage: '访问目的不能为空',
})
const response: any = await post(`/operator/reviews/${currentTask.value.taskId}/evidences/${row.evidenceId}/access`, { purpose })
window.open(response.data.url, '_blank', 'noopener,noreferrer')
} catch (error: any) {
if (error !== 'cancel' && error !== 'close') ElMessage.error(error.message || '佐证访问失败')
}
}
function reviewTypeText(value?: string) {
return value ? ({ identity: '实名认证', profile: '婚恋资料', local_eligibility: '本地资格' }[value] || value) : '-'
}
@@ -20,5 +20,9 @@ public class SensitiveAccessLog extends BaseEntity {
private String dataType;
/** 访问原因 */
private String reason;
/** 被访问的具体资源 ID,例如本地资格佐证 ID。 */
private String accessTarget;
/** success / denied / failed */
private String accessResult;
private String ip;
}
@@ -74,6 +74,7 @@ public class HomeDTO {
private String label;
private String icon;
private boolean enabled;
private String disabledReason;
private String targetPage;
}
@@ -5,9 +5,13 @@ 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.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.MatchEligibilityService;
import com.danyang.match.platform.match.service.RecommendationEngineService;
import com.danyang.match.platform.profile.entity.UserProfile;
import com.danyang.match.platform.profile.mapper.UserProfileMapper;
import com.danyang.match.platform.review.entity.ReviewTask;
@@ -39,9 +43,12 @@ public class HomeApplicationService {
private final UserProfileMapper userProfileMapper;
private final LocalEligibilityMapper localEligibilityMapper;
private final ReviewTaskMapper reviewTaskMapper;
private final MatchEligibilityService matchEligibilityService;
private final RecommendationEngineService recommendationEngineService;
private final HomeBannerService bannerService;
private final ActivityMapper activityMapper;
private final ContentArticleMapper contentArticleMapper;
private final ServiceLocationMapper serviceLocationMapper;
private final UserIdentityMapper userIdentityMapper;
/**
* 获取首页聚合数据
@@ -81,7 +88,7 @@ public class HomeApplicationService {
dto.setSafetyNotice(buildSafetyNotice());
// 10. 文章/课堂(V1.1 Content模块预留)
dto.setArticles(List.of());
dto.setArticles(buildArticlePreviews());
return dto;
}
@@ -105,7 +112,9 @@ public class HomeApplicationService {
state.setPhoneBound(user.getPhone() != null && !user.getPhone().isBlank());
// 实名状态
state.setVerified(true); // TODO: Phase 03 后接入真实判断
state.setVerified(userIdentityMapper.findByUserId(userId)
.map(identity -> Constants.VERIFICATION_STATUS_VERIFIED.equals(identity.getVerificationStatus()))
.orElse(false));
// 资料审核状态
UserProfile profile = userProfileMapper.findByUserId(userId).orElse(null);
@@ -139,14 +148,19 @@ public class HomeApplicationService {
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(isSearching(user) && isProfileApproved(user));
caps.setCanSearchUsers(isProfileApproved(user));
caps.setCanLike(isProfileApproved(user));
caps.setCanReceiveRecommendations(baseEligible
&& isSearching(user)
&& Boolean.TRUE.equals(user.getRecommendationEnabled()));
caps.setCanSearchUsers(baseEligible);
caps.setCanLike(baseEligible);
caps.setCanChat(false); // 由Match状态决定,首页层暂不展示
caps.setCanApplyMatchmakingActivity(isProfileApproved(user));
caps.setCanApplyCounseling(isProfileApproved(user));
caps.setCanApplyMatchmaker(isProfileApproved(user));
caps.setCanApplyMatchmakingActivity(baseEligible);
caps.setCanApplyCounseling(baseEligible);
caps.setCanApplyMatchmaker(baseEligible);
return caps;
}
@@ -172,14 +186,14 @@ public class HomeApplicationService {
// 固定8个入口,按V1.1规范顺序
List<HomeDTO.QuickEntry> entries = new ArrayList<>();
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/local-eligibility/local-eligibility"));
entries.add(entry("classroom", "婚恋课堂", "icon-class", true, "pages/local-eligibility/local-eligibility"));
entries.add(entry("premarital", "婚前辅导", "icon-premarital", caps.isCanApplyCounseling(), "pages/local-eligibility/local-eligibility"));
entries.add(entry("anti_fraud", "婚恋反诈", "icon-shield", true, "pages/local-eligibility/local-eligibility"));
entries.add(entry("service_station", "善缘服务站", "icon-location", true, "pages/local-eligibility/local-eligibility"));
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"));
return entries;
}
@@ -194,8 +208,15 @@ public class HomeApplicationService {
if (!isProfileApproved(user)) {
return List.of();
}
// TODO: Phase 07 后续接入推荐引擎实际数据
return List.of();
return recommendationEngineService.getDailyRecommendations(userId).stream()
.map(candidate -> {
HomeDTO.RecommendationItem item = new HomeDTO.RecommendationItem();
item.setUserId(candidate.getUserId());
item.setRecommendationLevel(candidate.getRecommendationLevel());
item.setRecommendationReasons(candidate.getRecommendationReasons());
return item;
})
.toList();
}
private List<HomeDTO.ServiceRecommendation> buildServiceRecommendations(User user) {
@@ -206,15 +227,15 @@ public class HomeApplicationService {
if (Constants.RELATIONSHIP_STATUS_SEARCHING.equals(status)) {
list.add(serviceRec("matchmaker", "一直没有遇到特别合适的人?",
"公益红娘可以根据你的条件,帮你一起看看合适的人。",
"申请公益红娘", "pages/matchmaker/matchmaker"));
"申请公益红娘", "/pages/matchmaker/matchmaker"));
} else if (Constants.RELATIONSHIP_STATUS_GETTING_TO_KNOW.equals(status)) {
list.add(serviceRec("counseling", "刚开始认识,不知道聊什么?",
"这里有一些第一次聊天的小建议。",
"看看沟通建议", "pages/local-eligibility/local-eligibility"));
"看看沟通建议", "/pages/counseling-intro/counseling-intro"));
} else if (Constants.RELATIONSHIP_STATUS_MATCHED_SUCCESSFULLY.equals(status)) {
list.add(serviceRec("premarital", "正在认真考虑未来?",
"可以了解善缘丹阳公益婚前辅导。",
"了解婚前辅导", "pages/local-eligibility/local-eligibility"));
"了解婚前辅导", "/pages/premarital-intro/premarital-intro"));
}
return list;
}
@@ -237,25 +258,41 @@ public class HomeApplicationService {
}
private HomeDTO.ServiceLocationCard buildServiceLocationCard() {
// V1.1: 当前只有一个固定服务站(万善公园婚姻登记处)
// 从数据库读取,暂时返回默认值(待Phase 19填充真实数据)
ServiceLocation location = serviceLocationMapper.selectOne(
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<ServiceLocation>()
.eq(ServiceLocation::getStatus, "active")
.isNull(ServiceLocation::getDeletedAt)
.orderByAsc(ServiceLocation::getCreatedAt)
.last("LIMIT 1"));
if (location == null) return null;
HomeDTO.ServiceLocationCard card = new HomeDTO.ServiceLocationCard();
card.setId("default");
card.setName("善缘丹阳公益服务站");
card.setShortName("万善公园婚姻登记处");
card.setAddress("丹阳市万善公园婚姻登记处");
card.setOpeningHoursText("周一至周五 09:00—17:00");
card.setContactPhoneDisplay("0511-******");
card.setId(location.getId());
card.setName(location.getName());
card.setShortName(location.getShortName());
card.setAddress(location.getAddress());
card.setOpeningHoursText(location.getOpeningHoursText());
card.setContactPhoneDisplay(location.getContactPhoneDisplay());
card.setServices(List.of("公益红娘", "婚恋咨询", "婚前辅导", "活动服务"));
return card;
}
private List<HomeDTO.ArticlePreview> buildArticlePreviews() {
return contentArticleMapper.findPublishedAll().stream().limit(4).map(article -> {
HomeDTO.ArticlePreview preview = new HomeDTO.ArticlePreview();
preview.setId(article.getId());
preview.setTitle(article.getTitle());
preview.setSummary(article.getSummary());
preview.setCoverFileId(article.getCoverFileId());
return preview;
}).toList();
}
private HomeDTO.SafetyNotice buildSafetyNotice() {
HomeDTO.SafetyNotice notice = new HomeDTO.SafetyNotice();
notice.setText("实名认证和人工审核可以提高信息真实性,"
+ "但交往过程中仍请谨慎涉及借款、投资和个人隐私。");
notice.setCtaText("查看婚恋反诈指南");
notice.setCtaTarget("pages/local-eligibility/local-eligibility");
notice.setCtaTarget("/pages/anti-fraud/anti-fraud");
return notice;
}
@@ -265,6 +302,12 @@ public class HomeApplicationService {
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);
@@ -278,6 +321,7 @@ public class HomeApplicationService {
e.setLabel(label);
e.setIcon(icon);
e.setEnabled(enabled);
e.setDisabledReason(enabled ? null : "完成实名认证和资料审核后可使用");
e.setTargetPage(targetPage);
return e;
}
@@ -1,5 +1,6 @@
package com.danyang.match.platform.local_eligibility.entity;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import com.danyang.match.platform.shared.base.BaseEntity;
import lombok.Data;
@@ -11,6 +12,7 @@ import lombok.EqualsAndHashCode;
public class LocalEligibilityEvidence extends BaseEntity {
private String localEligibilityId;
@TableField("file_key")
private String fileId;
/** employment_proof / residence_proof / household_proof / other */
private String evidenceType;
@@ -9,9 +9,11 @@ import com.danyang.match.platform.local_eligibility.entity.LocalEligibility;
import com.danyang.match.platform.local_eligibility.entity.LocalEligibilityEvidence;
import com.danyang.match.platform.local_eligibility.mapper.LocalEligibilityEvidenceMapper;
import com.danyang.match.platform.local_eligibility.mapper.LocalEligibilityMapper;
import com.danyang.match.platform.match.service.RecommendationEngineService;
import com.danyang.match.platform.photo.entity.UserPhoto;
import com.danyang.match.platform.photo.mapper.UserPhotoMapper;
import com.danyang.match.platform.review.entity.ReviewTask;
import com.danyang.match.platform.review.service.ReviewService;
import com.danyang.match.platform.shared.config.FeatureFlagConfig;
import com.danyang.match.platform.user.mapper.UserMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -22,6 +24,7 @@ import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.util.Collections;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
/**
@@ -38,6 +41,11 @@ public class LocalEligibilityService {
private final UserMapper userMapper;
private final ReviewService reviewService;
private final StringRedisTemplate redisTemplate;
private final FeatureFlagConfig featureFlags;
private final UserPhotoMapper userPhotoMapper;
private static final Set<String> ALLOWED_EVIDENCE_TYPES = Set.of(
"employment_proof", "residence_proof", "household_proof", "other");
/**
* GET /api/v1/local-eligibility — 查询当前用户本地资格状态
@@ -101,12 +109,35 @@ public class LocalEligibilityService {
*/
@Transactional
public LocalEligibilityStatusDTO uploadEvidence(String userId, LocalEligibilityEvidenceRequest req) {
if (!featureFlags.isLocalEvidenceUploadEnabled()) {
throw new IllegalStateException("本地资格佐证上传暂不可用");
}
if (req == null || !ALLOWED_EVIDENCE_TYPES.contains(req.getEvidenceType())) {
throw new IllegalArgumentException("不支持的佐证类型");
}
if (req.getFileId() == null || req.getFileId().isBlank()) {
throw new IllegalArgumentException("佐证文件不能为空");
}
LocalEligibility el = localEligibilityMapper.selectOne(
new LambdaQueryWrapper<LocalEligibility>().eq(LocalEligibility::getUserId, userId));
if (el == null) {
throw new IllegalStateException("请先声明本地资格");
}
long evidenceCount = evidenceMapper.selectCount(
new LambdaQueryWrapper<LocalEligibilityEvidence>()
.eq(LocalEligibilityEvidence::getLocalEligibilityId, el.getId()));
if (evidenceCount >= featureFlags.getLocalEvidenceMaxFiles()) {
throw new IllegalStateException("佐证文件数量已达到上限");
}
boolean owned = userPhotoMapper.findByUserIdOrderBySort(userId).stream()
.map(UserPhoto::getFileKey)
.anyMatch(req.getFileId()::equals);
if (!owned) {
throw new SecurityException("无权使用该佐证文件");
}
LocalEligibilityEvidence evidence = new LocalEligibilityEvidence();
evidence.setLocalEligibilityId(el.getId());
evidence.setEvidenceType(req.getEvidenceType());
@@ -172,8 +172,7 @@ public class MatchEligibilityService {
if (elOpt.isEmpty()) return false;
LocalEligibility el = elOpt.get();
// 仅 verified 状态允许推荐;pending_review/needs_evidence 等状态不允许
return Constants.PROFILE_STATUS_PENDING_REVIEW.equals(el.getVerificationStatus())
|| "verified".equals(el.getVerificationStatus());
return "verified".equals(el.getVerificationStatus());
}
/**
@@ -10,6 +10,9 @@ import com.danyang.match.platform.profile.entity.UserPreference;
import com.danyang.match.platform.profile.entity.UserProfile;
import com.danyang.match.platform.profile.mapper.UserPreferenceMapper;
import com.danyang.match.platform.profile.mapper.UserProfileMapper;
import com.danyang.match.platform.profile.entity.UserPrivacySettings;
import com.danyang.match.platform.profile.mapper.UserPrivacySettingsMapper;
import com.danyang.match.platform.local_eligibility.mapper.LocalEligibilityMapper;
import com.danyang.match.platform.relationship.mapper.UserRelationMapper;
import com.danyang.match.platform.shared.constant.Constants;
import com.danyang.match.platform.user.entity.User;
@@ -50,17 +53,25 @@ public class RecommendationEngineService {
private final ObjectMapper objectMapper = new ObjectMapper();
private final UserRelationMapper relationMapper;
private final UserMapper userMapper;
private final UserPrivacySettingsMapper privacySettingsMapper;
private final LocalEligibilityMapper localEligibilityMapper;
/**
* 获取每日推荐列表
*/
public List<RecommendationCandidateDTO> getDailyRecommendations(String userId) {
if (!isParticipantEligible(userId)) {
return List.of();
}
// 1. 尝试从缓存读取
String cacheKey = CACHE_KEY_PREFIX + userId + ":" + LocalDate.now();
String cached = redisTemplate.opsForValue().get(cacheKey);
if (cached != null) {
log.debug("推荐缓存命中: userId={}", userId);
return parseCachedRecommendations(cached);
return parseCachedRecommendations(cached).stream()
.filter(item -> isStillEligible(userId, item.getUserId()))
.limit(matchConfig.getDefaultDailyLimit())
.toList();
}
// 2. 排除已Pass冷却期的用户
@@ -106,6 +117,9 @@ public class RecommendationEngineService {
* 双重校验:推荐二次 Eligibility(防止缓存与实时状态不一致)
*/
public boolean isStillEligible(String userId, String targetUserId) {
if (userId == null || targetUserId == null || userId.equals(targetUserId)) return false;
if (!isParticipantEligible(userId) || !isParticipantEligible(targetUserId)) return false;
// 检查目标用户是否仍符合基本条件
UserProfile target = userProfileMapper.findByUserId(targetUserId).orElse(null);
if (target == null) return false;
@@ -123,6 +137,12 @@ public class RecommendationEngineService {
return false;
}
UserPreference myPref = preferenceMapper.findByUserId(userId).orElse(null);
UserPreference targetPref = preferenceMapper.findByUserId(targetUserId).orElse(null);
UserProfile myProfile = userProfileMapper.findByUserId(userId).orElse(null);
if (myPref == null || targetPref == null || myProfile == null) return false;
if (!checkBidirectionalRequired(myPref, target, targetPref, myProfile)) return false;
return true;
}
@@ -178,15 +198,9 @@ public class RecommendationEngineService {
.collect(Collectors.toList());
}
// 过滤掉账号异常用户(banned/suspended/closed)
// 最终实时资格过滤包含双向 Block、关系状态、推荐开关、隐私和可选本地资格门禁。
candidates = candidates.stream()
.filter(p -> {
return userMapper.findById(p.getUserId())
.map(u -> !Constants.ACCOUNT_STATUS_BANNED.equals(u.getAccountStatus())
&& !Constants.ACCOUNT_STATUS_SUSPENDED.equals(u.getAccountStatus())
&& !Constants.ACCOUNT_STATUS_CLOSED.equals(u.getAccountStatus()))
.orElse(false);
})
.filter(p -> isStillEligible(userId, p.getUserId()))
.collect(Collectors.toList());
return candidates;
@@ -227,8 +241,9 @@ public class RecommendationEngineService {
return userProfileMapper.findByUserId(userId).orElse(null);
}
private boolean checkBidirectionalRequired(UserPreference myPref, UserProfile candidate,
UserPreference targetPref, UserProfile myProfile) {
boolean checkBidirectionalRequired(UserPreference myPref, UserProfile candidate,
UserPreference targetPref, UserProfile myProfile) {
if (myPref == null || candidate == null || targetPref == null || myProfile == null) return false;
// 1. 年龄 Required 检查
if ("required".equals(myPref.getAgePriority()) && myPref.getMinAge() != null && myPref.getMaxAge() != null) {
int candidateAge = calculateAge(candidate.getBirthDate());
@@ -308,6 +323,24 @@ public class RecommendationEngineService {
return true;
}
private boolean isParticipantEligible(String userId) {
User user = userMapper.findById(userId).orElse(null);
if (user == null
|| !Constants.ACCOUNT_STATUS_NORMAL.equals(user.getAccountStatus())
|| !Constants.RELATIONSHIP_STATUS_SEARCHING.equals(user.getRelationshipStatus())
|| !Boolean.TRUE.equals(user.getRecommendationEnabled())) {
return false;
}
UserPrivacySettings privacy = privacySettingsMapper.findByUserId(userId).orElse(null);
if (privacy != null && !Boolean.TRUE.equals(privacy.getVisibleInRecommendations())) return false;
if (matchConfig.isLocalEligibilityRequired()) {
return localEligibilityMapper.findByUserId(userId)
.map(value -> "verified".equals(value.getVerificationStatus()))
.orElse(false);
}
return true;
}
private double calculateScore(String fromUserId, UserProfile target, UserPreference pref) {
// TODO: Phase 07 后续完善评分逻辑
return 50.0; // V1 简化:默认中间分
@@ -6,6 +6,7 @@ import com.amazonaws.services.s3.model.GeneratePresignedUrlRequest;
import com.danyang.match.platform.photo.entity.UserPhoto;
import com.danyang.match.platform.photo.mapper.UserPhotoMapper;
import com.danyang.match.platform.shared.constant.Constants;
import com.danyang.match.platform.shared.config.FeatureFlagConfig;
import lombok.RequiredArgsConstructor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -34,6 +35,7 @@ public class PhotoService {
private final UserPhotoMapper photoMapper;
private final AmazonS3 amazonS3;
private final FeatureFlagConfig featureFlags;
@Value("${object-storage.bucket-private}")
private String privateBucket;
@@ -85,6 +87,15 @@ public class PhotoService {
return result;
}
/** 仅供已经完成业务权限与资源归属校验的后台审核流程调用。 */
public Map<String, Object> getSignedUrlForAuthorizedReview(String fileKey) {
Date expiration = new Date(System.currentTimeMillis() + TimeUnit.MINUTES.toMillis(10));
GeneratePresignedUrlRequest request = new GeneratePresignedUrlRequest(privateBucket, fileKey)
.withMethod(com.amazonaws.HttpMethod.GET)
.withExpiration(expiration);
return Map.of("url", amazonS3.generatePresignedUrl(request).toString(), "expiresIn", 600);
}
/**
* 直接保存照片记录(后端直传场景)
*/
@@ -114,10 +125,13 @@ public class PhotoService {
@Transactional
public UserPhoto uploadAndSave(String userId, MultipartFile file, String photoType, int sortOrder) {
// 1. 安全校验
if ("local_eligibility".equals(photoType) && !isAllowedLocalEvidenceMimeType(file.getContentType())) {
throw new RuntimeException("不支持的佐证文件格式: " + file.getContentType());
}
if (!isAllowedMimeType(file.getContentType())) {
throw new RuntimeException("不支持的图片格式: " + file.getContentType());
}
if (!validateSize(file)) {
if (!validateSize(file, photoType)) {
throw new RuntimeException("图片大小超过限制(最大10MB)");
}
InputStream stream;
@@ -207,6 +221,24 @@ public class PhotoService {
return file.getSize() <= 10 * 1024 * 1024;
}
public boolean validateSize(MultipartFile file, String photoType) {
int maxSizeMB = "local_eligibility".equals(photoType) && featureFlags != null
? featureFlags.getLocalEvidenceMaxFileSizeMB() : 10;
return file.getSize() <= (long) maxSizeMB * 1024 * 1024;
}
private boolean isAllowedLocalEvidenceMimeType(String mimeType) {
if (mimeType == null || featureFlags == null) return false;
String extension = switch (mimeType.toLowerCase()) {
case "image/jpeg" -> "jpg";
case "image/png" -> "png";
case "application/pdf" -> "pdf";
default -> "";
};
return java.util.Arrays.stream(featureFlags.getLocalEvidenceAllowedTypes().split(","))
.map(String::trim).map(String::toLowerCase).anyMatch(extension::equals);
}
/**
* 软删除照片
*/
@@ -383,7 +383,8 @@ public class RelationshipService {
// ==================== 辅助方法 ====================
private boolean isBlocked(String userId, String targetUserId) {
return relationMapper.findByRelation(userId, targetUserId, Constants.RELATION_TYPE_BLOCKED).isPresent();
return relationMapper.findByRelation(userId, targetUserId, Constants.RELATION_TYPE_BLOCKED).isPresent()
|| relationMapper.findByRelation(targetUserId, userId, Constants.RELATION_TYPE_BLOCKED).isPresent();
}
private boolean hasMutualMatch(String userId, String targetUserId) {
@@ -3,6 +3,7 @@ package com.danyang.match.platform.review.controller;
import com.danyang.match.platform.shared.security.principal.OperatorPrincipal;
import com.danyang.match.platform.review.dto.ReviewTaskDetailDTO;
import com.danyang.match.platform.review.dto.ReviewResultDTO;
import com.danyang.match.platform.review.entity.ReviewTask;
import com.danyang.match.platform.review.service.ReviewService;
import com.danyang.match.platform.shared.dto.ApiResponse;
import lombok.RequiredArgsConstructor;
@@ -29,7 +30,7 @@ public class ReviewController {
* GET /api/v1/operator/reviews/stats
*/
@GetMapping
public ApiResponse<List<Map<String, Object>>> list(
public ApiResponse<List<ReviewTask>> list(
@AuthenticationPrincipal OperatorPrincipal principal,
@RequestParam(defaultValue = "0") Integer page,
@RequestParam(defaultValue = "20") Integer pageSize) {
@@ -46,12 +47,11 @@ public class ReviewController {
* GET /api/v1/operator/reviews/pending
*/
@GetMapping("/pending")
public ApiResponse<List<Map<String, Object>>> listPending(
public ApiResponse<List<ReviewTask>> listPending(
@AuthenticationPrincipal OperatorPrincipal principal,
@RequestParam(defaultValue = "1") Integer page,
@RequestParam(defaultValue = "20") Integer pageSize) {
// TODO: Phase 05 后续补充完整分页
return ApiResponse.ok(List.of());
return ApiResponse.ok(reviewService.listPendingTasks(Math.max(0, page - 1), pageSize));
}
/**
@@ -59,7 +59,7 @@ public class ReviewController {
* POST /api/v1/operator/reviews/{taskId}/claim
*/
@PostMapping("/{taskId}/claim")
@PreAuthorize("hasAuthority('review.process')")
@PreAuthorize("hasAnyAuthority('review.process', 'local_eligibility.review')")
public ApiResponse<Map<String, Object>> claimTask(
@AuthenticationPrincipal OperatorPrincipal principal,
@PathVariable String taskId) {
@@ -135,14 +135,51 @@ public class ReviewController {
return ApiResponse.ok(reviewService.reject(taskId, operatorId, comment, reasonCodes));
}
@PostMapping("/{taskId}/need-evidence")
@PreAuthorize("hasAuthority('local_eligibility.review')")
public ApiResponse<ReviewResultDTO> needEvidence(
@AuthenticationPrincipal OperatorPrincipal principal,
@PathVariable String taskId,
@RequestBody Map<String, String> body) {
return ApiResponse.ok(reviewService.needEvidence(taskId, principal.id(), body.get("comment")));
}
@PostMapping("/{taskId}/approve-local-eligibility")
@PreAuthorize("hasAuthority('local_eligibility.review')")
public ApiResponse<ReviewResultDTO> approveLocalEligibility(
@AuthenticationPrincipal OperatorPrincipal principal,
@PathVariable String taskId,
@RequestBody Map<String, String> body) {
return ApiResponse.ok(reviewService.approveLocalEligibility(taskId, principal.id(), body.get("comment")));
}
@PostMapping("/{taskId}/reject-local-eligibility")
@PreAuthorize("hasAuthority('local_eligibility.review')")
public ApiResponse<ReviewResultDTO> rejectLocalEligibility(
@AuthenticationPrincipal OperatorPrincipal principal,
@PathVariable String taskId,
@RequestBody Map<String, String> body) {
return ApiResponse.ok(reviewService.rejectLocalEligibility(taskId, principal.id(), body.get("comment")));
}
@PostMapping("/{taskId}/evidences/{evidenceId}/access")
@PreAuthorize("hasAuthority('local_eligibility.evidence.view')")
public ApiResponse<Map<String, Object>> accessEvidence(
@AuthenticationPrincipal OperatorPrincipal principal,
@PathVariable String taskId,
@PathVariable String evidenceId,
@RequestBody Map<String, String> body) {
return ApiResponse.ok(reviewService.accessLocalEligibilityEvidence(
taskId, evidenceId, principal.id(), body.get("purpose")));
}
/**
* 获取审核原因选项
* GET /api/v1/operator/reviews/reasons
*/
@GetMapping("/reasons")
public ApiResponse<List<Map<String, String>>> getReasons() {
// TODO: Phase 05 后续补充从数据库加载
return ApiResponse.ok(List.of());
return ApiResponse.ok(reviewService.getReasonOptions());
}
}
@@ -25,6 +25,7 @@ public class ReviewTaskDetailDTO {
private Map<String, Object> userPreference;
private List<Map<String, Object>> photos;
private List<Map<String, Object>> reviewItems;
private List<Map<String, Object>> localEligibilityEvidences;
// 审核原因选项
private List<Map<String, String>> reasonOptions;
@@ -5,6 +5,8 @@ import com.danyang.match.platform.review.entity.ReviewTask;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.ResultMap;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import org.apache.ibatis.annotations.Param;
import java.util.List;
import java.util.Optional;
@@ -12,6 +14,13 @@ import java.util.Optional;
@Mapper
public interface ReviewTaskMapper extends BaseMapper<ReviewTask> {
@Update("UPDATE review_tasks SET status = 'processing', assigned_operator_id = #{operatorId}, " +
"started_at = #{startedAt}, updated_at = NOW() " +
"WHERE id = #{taskId} AND status = 'pending' AND deleted_at IS NULL")
int claimTaskAtomic(@Param("taskId") String taskId,
@Param("operatorId") String operatorId,
@Param("startedAt") java.time.Instant startedAt);
@Select("SELECT * FROM review_tasks WHERE status = 'pending' ORDER BY submitted_at ASC LIMIT 1 FOR UPDATE SKIP LOCKED")
Optional<ReviewTask> findNextPending();
@@ -6,8 +6,11 @@ import com.danyang.match.platform.audit.entity.SensitiveAccessLog;
import com.danyang.match.platform.audit.mapper.AuditLogMapper;
import com.danyang.match.platform.audit.mapper.SensitiveAccessLogMapper;
import com.danyang.match.platform.local_eligibility.entity.LocalEligibility;
import com.danyang.match.platform.local_eligibility.entity.LocalEligibilityEvidence;
import com.danyang.match.platform.local_eligibility.mapper.LocalEligibilityEvidenceMapper;
import com.danyang.match.platform.local_eligibility.mapper.LocalEligibilityMapper;
import com.danyang.match.platform.notification.service.NotificationService;
import com.danyang.match.platform.photo.service.PhotoService;
import com.danyang.match.platform.profile.dto.PublicUserProfileDTO;
import com.danyang.match.platform.profile.mapper.UserProfileMapper;
import com.danyang.match.platform.review.dto.ReviewTaskDetailDTO;
@@ -40,8 +43,10 @@ public class ReviewService {
private final UserProfileMapper userProfileMapper;
private final AuditLogMapper auditLogMapper;
private final LocalEligibilityMapper localEligibilityMapper;
private final LocalEligibilityEvidenceMapper localEligibilityEvidenceMapper;
private final SensitiveAccessLogMapper sensitiveAccessLogMapper;
private final NotificationService notificationService;
private final PhotoService photoService;
/**
* 领取审核任务(原子操作,防并发)
@@ -50,17 +55,7 @@ public class ReviewService {
@Transactional
public Optional<ReviewTask> claimTask(String operatorId, String taskId) {
// 先查找任务
ReviewTask task = new ReviewTask();
task.setId(taskId);
// 原子更新:只有 status = pending 时才能领取
int rows = reviewTaskMapper.update(null,
new LambdaUpdateWrapper<ReviewTask>()
.eq(ReviewTask::getId, taskId)
.eq(ReviewTask::getStatus, Constants.REVIEW_STATUS_PENDING)
.set(ReviewTask::getStatus, Constants.REVIEW_STATUS_PROCESSING)
.set(ReviewTask::getAssignedOperatorId, operatorId)
.set(ReviewTask::getStartedAt, Instant.now()));
int rows = reviewTaskMapper.claimTaskAtomic(taskId, operatorId, Instant.now());
if (rows == 0) {
return Optional.empty(); // 已被其他审核员领取
@@ -93,6 +88,12 @@ public class ReviewService {
return stats;
}
public List<Map<String, String>> getReasonOptions() {
return reviewReasonMapper.findAllEnabled().stream()
.map(reason -> Map.of("code", reason.getCode(), "name", reason.getName()))
.toList();
}
/**
* 获取审核任务详情
*/
@@ -143,6 +144,24 @@ public class ReviewService {
.map(r -> Map.of("code", r.getCode(), "name", r.getName()))
.toList());
if (Constants.REVIEW_TYPE_LOCAL_ELIGIBILITY.equals(task.getReviewType())) {
LocalEligibility eligibility = findLocalEligibility(task.getUserId());
List<LocalEligibilityEvidence> evidences = eligibility == null ? List.of()
: localEligibilityEvidenceMapper.selectList(
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<LocalEligibilityEvidence>()
.eq(LocalEligibilityEvidence::getLocalEligibilityId, eligibility.getId())
.isNull(LocalEligibilityEvidence::getDeletedAt)
.orderByAsc(LocalEligibilityEvidence::getCreatedAt));
dto.setLocalEligibilityEvidences(evidences.stream().map(e -> {
Map<String, Object> item = new HashMap<>();
item.put("evidenceId", e.getId());
item.put("evidenceType", e.getEvidenceType());
item.put("status", e.getStatus());
item.put("createdAt", e.getCreatedAt());
return item;
}).toList());
}
return dto;
}
@@ -325,6 +344,7 @@ public class ReviewService {
if (!Constants.REVIEW_TYPE_LOCAL_ELIGIBILITY.equals(task.getReviewType())) {
throw new RuntimeException("该任务不是本地资格审核");
}
requireClaimOwner(task, operatorId);
task.setStatus(Constants.REVIEW_STATUS_NEED_EDIT);
task.setFinalComment(comment);
@@ -332,15 +352,10 @@ public class ReviewService {
reviewTaskMapper.updateById(task);
// 更新本地资格状态
LocalEligibility el = localEligibilityMapper.selectOne(
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<LocalEligibility>()
.eq(LocalEligibility::getUserId, task.getUserId())
);
if (el != null) {
el.setVerificationStatus("needs_evidence");
el.setReviewTaskId(null);
localEligibilityMapper.updateById(el);
}
LocalEligibility el = requireLocalEligibility(task.getUserId());
el.setVerificationStatus("needs_evidence");
el.setReviewTaskId(null);
localEligibilityMapper.updateById(el);
saveAuditLog(operatorId, "NEED_EVIDENCE", "local_eligibility_review", taskId, null, comment);
// V1.1: 通知用户需补充佐证
@@ -371,6 +386,7 @@ public class ReviewService {
if (!Constants.REVIEW_TYPE_LOCAL_ELIGIBILITY.equals(task.getReviewType())) {
throw new RuntimeException("该任务不是本地资格审核");
}
requireClaimOwner(task, operatorId);
task.setStatus(Constants.REVIEW_STATUS_APPROVED);
task.setFinalComment(comment);
@@ -378,17 +394,12 @@ public class ReviewService {
reviewTaskMapper.updateById(task);
// 更新本地资格状态
LocalEligibility el = localEligibilityMapper.selectOne(
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<LocalEligibility>()
.eq(LocalEligibility::getUserId, task.getUserId())
);
if (el != null) {
el.setVerificationStatus("verified");
el.setReviewTaskId(taskId);
el.setVerifiedAt(Instant.now());
el.setVerifiedBy(operatorId);
localEligibilityMapper.updateById(el);
}
LocalEligibility el = requireLocalEligibility(task.getUserId());
el.setVerificationStatus("verified");
el.setReviewTaskId(taskId);
el.setVerifiedAt(Instant.now());
el.setVerifiedBy(operatorId);
localEligibilityMapper.updateById(el);
saveAuditLog(operatorId, "APPROVE_LOCAL_ELIGIBILITY", "local_eligibility_review", taskId, null, comment);
// V1.1: 通知用户本地资格已通过核验
@@ -419,6 +430,7 @@ public class ReviewService {
if (!Constants.REVIEW_TYPE_LOCAL_ELIGIBILITY.equals(task.getReviewType())) {
throw new RuntimeException("该任务不是本地资格审核");
}
requireClaimOwner(task, operatorId);
task.setStatus(Constants.REVIEW_STATUS_REJECTED);
task.setFinalComment(comment);
@@ -426,17 +438,12 @@ public class ReviewService {
reviewTaskMapper.updateById(task);
// 更新本地资格状态
LocalEligibility el = localEligibilityMapper.selectOne(
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<LocalEligibility>()
.eq(LocalEligibility::getUserId, task.getUserId())
);
if (el != null) {
el.setVerificationStatus("rejected");
el.setReviewTaskId(taskId);
el.setVerifiedAt(Instant.now());
el.setVerifiedBy(operatorId);
localEligibilityMapper.updateById(el);
}
LocalEligibility el = requireLocalEligibility(task.getUserId());
el.setVerificationStatus("rejected");
el.setReviewTaskId(taskId);
el.setVerifiedAt(Instant.now());
el.setVerifiedBy(operatorId);
localEligibilityMapper.updateById(el);
saveAuditLog(operatorId, "REJECT_LOCAL_ELIGIBILITY", "local_eligibility_review", taskId, null, comment);
// V1.1: 通知用户本地资格未通过
@@ -456,16 +463,47 @@ public class ReviewService {
* 记录佐证文件访问审计(INV-011)
* 审核员查看佐证文件时必须调用此方法记录审计日志
*/
public void logEvidenceAccess(String operatorId, String userId, String evidenceId, String reason) {
public void logEvidenceAccess(String operatorId, String userId, String evidenceId, String reason, String result) {
SensitiveAccessLog logEntry = new SensitiveAccessLog();
logEntry.setOperatorId(operatorId);
logEntry.setUserId(userId);
logEntry.setDataType(Constants.SENSITIVE_DATA_TYPE_LOCAL_EVIDENCE);
logEntry.setReason(reason != null ? reason : "本地资格审核");
logEntry.setAccessTarget(evidenceId);
logEntry.setAccessResult(result);
sensitiveAccessLogMapper.insert(logEntry);
log.info("佐证文件访问审计: operatorId={}, userId={}, evidenceId={}", operatorId, userId, evidenceId);
}
/** 权限注解之外,再校验任务领取人和佐证归属;成功与拒绝均写审计。 */
public Map<String, Object> accessLocalEligibilityEvidence(
String taskId, String evidenceId, String operatorId, String purpose) {
if (purpose == null || purpose.isBlank()) {
throw new RuntimeException("查看佐证必须填写访问目的");
}
ReviewTask task = reviewTaskMapper.selectById(taskId);
if (task == null || !Constants.REVIEW_TYPE_LOCAL_ELIGIBILITY.equals(task.getReviewType())) {
if (task != null) {
logEvidenceAccess(operatorId, task.getUserId(), evidenceId, purpose, "denied");
}
throw new RuntimeException("本地资格审核任务不存在");
}
try {
requireClaimOwner(task, operatorId);
LocalEligibility eligibility = requireLocalEligibility(task.getUserId());
LocalEligibilityEvidence evidence = localEligibilityEvidenceMapper.selectById(evidenceId);
if (evidence == null || !eligibility.getId().equals(evidence.getLocalEligibilityId())) {
throw new RuntimeException("佐证不存在或不属于当前审核任务");
}
Map<String, Object> result = photoService.getSignedUrlForAuthorizedReview(evidence.getFileId());
logEvidenceAccess(operatorId, task.getUserId(), evidenceId, purpose, "success");
return result;
} catch (RuntimeException ex) {
logEvidenceAccess(operatorId, task.getUserId(), evidenceId, purpose, "denied");
throw ex;
}
}
/**
* 查询某用户的本地资格佐证访问记录
*/
@@ -480,6 +518,24 @@ public class ReviewService {
// ==================== 私有方法 ====================
private void requireClaimOwner(ReviewTask task, String operatorId) {
if (!Objects.equals(operatorId, task.getAssignedOperatorId())) {
throw new RuntimeException("请先领取该审核任务,且仅领取人可以处理");
}
}
private LocalEligibility findLocalEligibility(String userId) {
return localEligibilityMapper.selectOne(
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<LocalEligibility>()
.eq(LocalEligibility::getUserId, userId));
}
private LocalEligibility requireLocalEligibility(String userId) {
LocalEligibility eligibility = findLocalEligibility(userId);
if (eligibility == null) throw new RuntimeException("本地资格记录不存在");
return eligibility;
}
private void updateProfileStatus(String userId, String status) {
userProfileMapper.update(null,
new LambdaUpdateWrapper<com.danyang.match.platform.profile.entity.UserProfile>()
@@ -47,7 +47,9 @@ public class FeatureFlagConfig {
/** 单个文件最大大小(MB) */
private int localEvidenceMaxFileSizeMB = 10;
/** 允许的文件类型 */
private String localEvidenceAllowedTypes = "jpg,png,pdf";
private String localEvidenceAllowedTypes = "jpg,png";
/** 佐证材料保留天数;实际清理由运维任务按此配置执行 */
private int localEvidenceRetentionDays = 365;
// ---- V1.1 咨询附件私存储 ----
/** 咨询附件是否使用私有存储桶 */
@@ -111,6 +113,9 @@ public class FeatureFlagConfig {
public String getLocalEvidenceAllowedTypes() { return localEvidenceAllowedTypes; }
public void setLocalEvidenceAllowedTypes(String localEvidenceAllowedTypes) { this.localEvidenceAllowedTypes = localEvidenceAllowedTypes; }
public int getLocalEvidenceRetentionDays() { return localEvidenceRetentionDays; }
public void setLocalEvidenceRetentionDays(int localEvidenceRetentionDays) { this.localEvidenceRetentionDays = localEvidenceRetentionDays; }
public boolean isCounselingAttachmentsPrivate() { return counselingAttachmentsPrivate; }
public void setCounselingAttachmentsPrivate(boolean counselingAttachmentsPrivate) { this.counselingAttachmentsPrivate = counselingAttachmentsPrivate; }
@@ -140,6 +145,7 @@ public class FeatureFlagConfig {
flags.put("localEvidenceMaxFiles", localEvidenceMaxFiles);
flags.put("localEvidenceMaxFileSizeMB", localEvidenceMaxFileSizeMB);
flags.put("localEvidenceAllowedTypes", localEvidenceAllowedTypes);
flags.put("localEvidenceRetentionDays", localEvidenceRetentionDays);
flags.put("counselingAttachmentsPrivate", counselingAttachmentsPrivate);
flags.put("counselingAttachmentsBucket", counselingAttachmentsBucket);
return flags;
@@ -7,7 +7,7 @@ import static org.junit.jupiter.api.Assertions.*;
class PhotoServiceTest {
private final PhotoService photoService = new PhotoService(null, null);
private final PhotoService photoService = new PhotoService(null, null, null);
@Test
void testValidateMagicNumberJpeg() {