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>
<el-table-column label="操作" width="120" fixed="right"> <el-table-column label="操作" width="120" fixed="right">
<template #default="{ row }"> <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> <span v-else class="muted">只读</span>
</template> </template>
</el-table-column> </el-table-column>
@@ -85,7 +85,24 @@
</el-descriptions> </el-descriptions>
<el-empty v-else description="暂无资料快照" /> <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> <h3 class="drawer-heading">审核操作</h3>
<el-form label-position="top"> <el-form label-position="top">
<el-form-item label="审核意见"> <el-form-item label="审核意见">
@@ -98,7 +115,7 @@
</el-form-item> </el-form-item>
<div class="review-actions"> <div class="review-actions">
<el-button type="success" :loading="submitting" @click="runDecision('approve', '审核通过')">通过</el-button> <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> <el-button type="danger" :loading="submitting" @click="runDecision('reject', '拒绝资料')">拒绝</el-button>
</div> </div>
</el-form> </el-form>
@@ -127,6 +144,16 @@ const currentTask = ref<any>(null)
const reasonOptions = ref<any[]>([]) const reasonOptions = ref<any[]>([])
const reviewForm = ref({ comment: '', reasonCodes: [] as string[] }) const reviewForm = ref({ comment: '', reasonCodes: [] as string[] })
const canProcess = computed(() => authStore.permissions.includes('review.process')) 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 pendingTasks = computed(() => tasks.value.filter((task) => !task.assignedOperatorId))
const myTasks = computed(() => tasks.value.filter((task) => task.assignedOperatorId === authStore.operator?.operatorId)) 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 submitting.value = true
try { 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, comment: reviewForm.value.comment,
reasonCodes: reviewForm.value.reasonCodes, 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) { function reviewTypeText(value?: string) {
return value ? ({ identity: '实名认证', profile: '婚恋资料', local_eligibility: '本地资格' }[value] || value) : '-' return value ? ({ identity: '实名认证', profile: '婚恋资料', local_eligibility: '本地资格' }[value] || value) : '-'
} }
@@ -20,5 +20,9 @@ public class SensitiveAccessLog extends BaseEntity {
private String dataType; private String dataType;
/** 访问原因 */ /** 访问原因 */
private String reason; private String reason;
/** 被访问的具体资源 ID,例如本地资格佐证 ID。 */
private String accessTarget;
/** success / denied / failed */
private String accessResult;
private String ip; private String ip;
} }
@@ -74,6 +74,7 @@ public class HomeDTO {
private String label; private String label;
private String icon; private String icon;
private boolean enabled; private boolean enabled;
private String disabledReason;
private String targetPage; 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.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.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.entity.LocalEligibility;
import com.danyang.match.platform.local_eligibility.mapper.LocalEligibilityMapper; 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.entity.UserProfile;
import com.danyang.match.platform.profile.mapper.UserProfileMapper; import com.danyang.match.platform.profile.mapper.UserProfileMapper;
import com.danyang.match.platform.review.entity.ReviewTask; import com.danyang.match.platform.review.entity.ReviewTask;
@@ -39,9 +43,12 @@ public class HomeApplicationService {
private final UserProfileMapper userProfileMapper; private final UserProfileMapper userProfileMapper;
private final LocalEligibilityMapper localEligibilityMapper; private final LocalEligibilityMapper localEligibilityMapper;
private final ReviewTaskMapper reviewTaskMapper; private final ReviewTaskMapper reviewTaskMapper;
private final MatchEligibilityService matchEligibilityService; private final RecommendationEngineService recommendationEngineService;
private final HomeBannerService bannerService; private final HomeBannerService bannerService;
private final ActivityMapper activityMapper; 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()); dto.setSafetyNotice(buildSafetyNotice());
// 10. 文章/课堂(V1.1 Content模块预留) // 10. 文章/课堂(V1.1 Content模块预留)
dto.setArticles(List.of()); dto.setArticles(buildArticlePreviews());
return dto; return dto;
} }
@@ -105,7 +112,9 @@ public class HomeApplicationService {
state.setPhoneBound(user.getPhone() != null && !user.getPhone().isBlank()); 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); UserProfile profile = userProfileMapper.findByUserId(userId).orElse(null);
@@ -139,14 +148,19 @@ public class HomeApplicationService {
return new HomeDTO.Capabilities(); return new HomeDTO.Capabilities();
} }
boolean baseEligible = Constants.ACCOUNT_STATUS_NORMAL.equals(user.getAccountStatus())
&& isIdentityVerified(userId)
&& isProfileApproved(user);
HomeDTO.Capabilities caps = new HomeDTO.Capabilities(); HomeDTO.Capabilities caps = new HomeDTO.Capabilities();
caps.setCanReceiveRecommendations(isSearching(user) && isProfileApproved(user)); caps.setCanReceiveRecommendations(baseEligible
caps.setCanSearchUsers(isProfileApproved(user)); && isSearching(user)
caps.setCanLike(isProfileApproved(user)); && Boolean.TRUE.equals(user.getRecommendationEnabled()));
caps.setCanSearchUsers(baseEligible);
caps.setCanLike(baseEligible);
caps.setCanChat(false); // 由Match状态决定,首页层暂不展示 caps.setCanChat(false); // 由Match状态决定,首页层暂不展示
caps.setCanApplyMatchmakingActivity(isProfileApproved(user)); caps.setCanApplyMatchmakingActivity(baseEligible);
caps.setCanApplyCounseling(isProfileApproved(user)); caps.setCanApplyCounseling(baseEligible);
caps.setCanApplyMatchmaker(isProfileApproved(user)); caps.setCanApplyMatchmaker(baseEligible);
return caps; return caps;
} }
@@ -172,14 +186,14 @@ public class HomeApplicationService {
// 固定8个入口,按V1.1规范顺序 // 固定8个入口,按V1.1规范顺序
List<HomeDTO.QuickEntry> entries = new ArrayList<>(); List<HomeDTO.QuickEntry> entries = new ArrayList<>();
entries.add(entry("find_object", "找对象", "icon-dating", caps.isCanSearchUsers(), "pages/discover/discover")); 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("matchmaker", "公益红娘", "icon-matchmaker", caps.isCanApplyMatchmaker(), "/pages/matchmaker/matchmaker"));
entries.add(entry("activities", "公益活动", "icon-activity", true, "pages/activity/activity")); 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("counseling", "婚恋咨询", "icon-counseling", caps.isCanApplyCounseling(), "/pages/counseling-intro/counseling-intro"));
entries.add(entry("classroom", "婚恋课堂", "icon-class", true, "pages/local-eligibility/local-eligibility")); entries.add(entry("classroom", "婚恋课堂", "icon-class", true, "/pages/content/content"));
entries.add(entry("premarital", "婚前辅导", "icon-premarital", caps.isCanApplyCounseling(), "pages/local-eligibility/local-eligibility")); entries.add(entry("premarital", "婚前辅导", "icon-premarital", caps.isCanApplyCounseling(), "/pages/premarital-intro/premarital-intro"));
entries.add(entry("anti_fraud", "婚恋反诈", "icon-shield", true, "pages/local-eligibility/local-eligibility")); entries.add(entry("anti_fraud", "婚恋反诈", "icon-shield", true, "/pages/anti-fraud/anti-fraud"));
entries.add(entry("service_station", "善缘服务站", "icon-location", true, "pages/local-eligibility/local-eligibility")); entries.add(entry("service_station", "善缘服务站", "icon-location", true, "/pages/service-station/service-station"));
return entries; return entries;
} }
@@ -194,8 +208,15 @@ public class HomeApplicationService {
if (!isProfileApproved(user)) { if (!isProfileApproved(user)) {
return List.of(); return List.of();
} }
// TODO: Phase 07 后续接入推荐引擎实际数据 return recommendationEngineService.getDailyRecommendations(userId).stream()
return List.of(); .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) { private List<HomeDTO.ServiceRecommendation> buildServiceRecommendations(User user) {
@@ -206,15 +227,15 @@ public class HomeApplicationService {
if (Constants.RELATIONSHIP_STATUS_SEARCHING.equals(status)) { if (Constants.RELATIONSHIP_STATUS_SEARCHING.equals(status)) {
list.add(serviceRec("matchmaker", "一直没有遇到特别合适的人?", list.add(serviceRec("matchmaker", "一直没有遇到特别合适的人?",
"公益红娘可以根据你的条件,帮你一起看看合适的人。", "公益红娘可以根据你的条件,帮你一起看看合适的人。",
"申请公益红娘", "pages/matchmaker/matchmaker")); "申请公益红娘", "/pages/matchmaker/matchmaker"));
} else if (Constants.RELATIONSHIP_STATUS_GETTING_TO_KNOW.equals(status)) { } else if (Constants.RELATIONSHIP_STATUS_GETTING_TO_KNOW.equals(status)) {
list.add(serviceRec("counseling", "刚开始认识,不知道聊什么?", list.add(serviceRec("counseling", "刚开始认识,不知道聊什么?",
"这里有一些第一次聊天的小建议。", "这里有一些第一次聊天的小建议。",
"看看沟通建议", "pages/local-eligibility/local-eligibility")); "看看沟通建议", "/pages/counseling-intro/counseling-intro"));
} else if (Constants.RELATIONSHIP_STATUS_MATCHED_SUCCESSFULLY.equals(status)) { } else if (Constants.RELATIONSHIP_STATUS_MATCHED_SUCCESSFULLY.equals(status)) {
list.add(serviceRec("premarital", "正在认真考虑未来?", list.add(serviceRec("premarital", "正在认真考虑未来?",
"可以了解善缘丹阳公益婚前辅导。", "可以了解善缘丹阳公益婚前辅导。",
"了解婚前辅导", "pages/local-eligibility/local-eligibility")); "了解婚前辅导", "/pages/premarital-intro/premarital-intro"));
} }
return list; return list;
} }
@@ -237,25 +258,41 @@ public class HomeApplicationService {
} }
private HomeDTO.ServiceLocationCard buildServiceLocationCard() { private HomeDTO.ServiceLocationCard buildServiceLocationCard() {
// V1.1: 当前只有一个固定服务站(万善公园婚姻登记处) ServiceLocation location = serviceLocationMapper.selectOne(
// 从数据库读取,暂时返回默认值(待Phase 19填充真实数据) 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(); HomeDTO.ServiceLocationCard card = new HomeDTO.ServiceLocationCard();
card.setId("default"); card.setId(location.getId());
card.setName("善缘丹阳公益服务站"); card.setName(location.getName());
card.setShortName("万善公园婚姻登记处"); card.setShortName(location.getShortName());
card.setAddress("丹阳市万善公园婚姻登记处"); card.setAddress(location.getAddress());
card.setOpeningHoursText("周一至周五 09:00—17:00"); card.setOpeningHoursText(location.getOpeningHoursText());
card.setContactPhoneDisplay("0511-******"); card.setContactPhoneDisplay(location.getContactPhoneDisplay());
card.setServices(List.of("公益红娘", "婚恋咨询", "婚前辅导", "活动服务")); card.setServices(List.of("公益红娘", "婚恋咨询", "婚前辅导", "活动服务"));
return card; 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() { private HomeDTO.SafetyNotice buildSafetyNotice() {
HomeDTO.SafetyNotice notice = new HomeDTO.SafetyNotice(); HomeDTO.SafetyNotice notice = new HomeDTO.SafetyNotice();
notice.setText("实名认证和人工审核可以提高信息真实性," notice.setText("实名认证和人工审核可以提高信息真实性,"
+ "但交往过程中仍请谨慎涉及借款、投资和个人隐私。"); + "但交往过程中仍请谨慎涉及借款、投资和个人隐私。");
notice.setCtaText("查看婚恋反诈指南"); notice.setCtaText("查看婚恋反诈指南");
notice.setCtaTarget("pages/local-eligibility/local-eligibility"); notice.setCtaTarget("/pages/anti-fraud/anti-fraud");
return notice; return notice;
} }
@@ -265,6 +302,12 @@ public class HomeApplicationService {
return Constants.RELATIONSHIP_STATUS_SEARCHING.equals(user.getRelationshipStatus()); 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) { private boolean isProfileApproved(User user) {
String status = userProfileMapper.findByUserId(user.getId()) String status = userProfileMapper.findByUserId(user.getId())
.map(UserProfile::getProfileStatus).orElse(null); .map(UserProfile::getProfileStatus).orElse(null);
@@ -278,6 +321,7 @@ public class HomeApplicationService {
e.setLabel(label); e.setLabel(label);
e.setIcon(icon); e.setIcon(icon);
e.setEnabled(enabled); e.setEnabled(enabled);
e.setDisabledReason(enabled ? null : "完成实名认证和资料审核后可使用");
e.setTargetPage(targetPage); e.setTargetPage(targetPage);
return e; return e;
} }
@@ -1,5 +1,6 @@
package com.danyang.match.platform.local_eligibility.entity; package com.danyang.match.platform.local_eligibility.entity;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName; import com.baomidou.mybatisplus.annotation.TableName;
import com.danyang.match.platform.shared.base.BaseEntity; import com.danyang.match.platform.shared.base.BaseEntity;
import lombok.Data; import lombok.Data;
@@ -11,6 +12,7 @@ import lombok.EqualsAndHashCode;
public class LocalEligibilityEvidence extends BaseEntity { public class LocalEligibilityEvidence extends BaseEntity {
private String localEligibilityId; private String localEligibilityId;
@TableField("file_key")
private String fileId; private String fileId;
/** employment_proof / residence_proof / household_proof / other */ /** employment_proof / residence_proof / household_proof / other */
private String evidenceType; 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.entity.LocalEligibilityEvidence;
import com.danyang.match.platform.local_eligibility.mapper.LocalEligibilityEvidenceMapper; import com.danyang.match.platform.local_eligibility.mapper.LocalEligibilityEvidenceMapper;
import com.danyang.match.platform.local_eligibility.mapper.LocalEligibilityMapper; 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.entity.ReviewTask;
import com.danyang.match.platform.review.service.ReviewService; 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 com.danyang.match.platform.user.mapper.UserMapper;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@@ -22,6 +24,7 @@ import org.springframework.transaction.annotation.Transactional;
import java.time.Instant; import java.time.Instant;
import java.util.Collections; import java.util.Collections;
import java.util.List; import java.util.List;
import java.util.Set;
import java.util.stream.Collectors; import java.util.stream.Collectors;
/** /**
@@ -38,6 +41,11 @@ public class LocalEligibilityService {
private final UserMapper userMapper; private final UserMapper userMapper;
private final ReviewService reviewService; private final ReviewService reviewService;
private final StringRedisTemplate redisTemplate; 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 — 查询当前用户本地资格状态 * GET /api/v1/local-eligibility — 查询当前用户本地资格状态
@@ -101,12 +109,35 @@ public class LocalEligibilityService {
*/ */
@Transactional @Transactional
public LocalEligibilityStatusDTO uploadEvidence(String userId, LocalEligibilityEvidenceRequest req) { 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( LocalEligibility el = localEligibilityMapper.selectOne(
new LambdaQueryWrapper<LocalEligibility>().eq(LocalEligibility::getUserId, userId)); new LambdaQueryWrapper<LocalEligibility>().eq(LocalEligibility::getUserId, userId));
if (el == null) { if (el == null) {
throw new IllegalStateException("请先声明本地资格"); 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(); LocalEligibilityEvidence evidence = new LocalEligibilityEvidence();
evidence.setLocalEligibilityId(el.getId()); evidence.setLocalEligibilityId(el.getId());
evidence.setEvidenceType(req.getEvidenceType()); evidence.setEvidenceType(req.getEvidenceType());
@@ -172,8 +172,7 @@ public class MatchEligibilityService {
if (elOpt.isEmpty()) return false; if (elOpt.isEmpty()) return false;
LocalEligibility el = elOpt.get(); LocalEligibility el = elOpt.get();
// 仅 verified 状态允许推荐;pending_review/needs_evidence 等状态不允许 // 仅 verified 状态允许推荐;pending_review/needs_evidence 等状态不允许
return Constants.PROFILE_STATUS_PENDING_REVIEW.equals(el.getVerificationStatus()) return "verified".equals(el.getVerificationStatus());
|| "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.entity.UserProfile;
import com.danyang.match.platform.profile.mapper.UserPreferenceMapper; import com.danyang.match.platform.profile.mapper.UserPreferenceMapper;
import com.danyang.match.platform.profile.mapper.UserProfileMapper; 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.relationship.mapper.UserRelationMapper;
import com.danyang.match.platform.shared.constant.Constants; import com.danyang.match.platform.shared.constant.Constants;
import com.danyang.match.platform.user.entity.User; import com.danyang.match.platform.user.entity.User;
@@ -50,17 +53,25 @@ public class RecommendationEngineService {
private final ObjectMapper objectMapper = new ObjectMapper(); private final ObjectMapper objectMapper = new ObjectMapper();
private final UserRelationMapper relationMapper; private final UserRelationMapper relationMapper;
private final UserMapper userMapper; private final UserMapper userMapper;
private final UserPrivacySettingsMapper privacySettingsMapper;
private final LocalEligibilityMapper localEligibilityMapper;
/** /**
* 获取每日推荐列表 * 获取每日推荐列表
*/ */
public List<RecommendationCandidateDTO> getDailyRecommendations(String userId) { public List<RecommendationCandidateDTO> getDailyRecommendations(String userId) {
if (!isParticipantEligible(userId)) {
return List.of();
}
// 1. 尝试从缓存读取 // 1. 尝试从缓存读取
String cacheKey = CACHE_KEY_PREFIX + userId + ":" + LocalDate.now(); String cacheKey = CACHE_KEY_PREFIX + userId + ":" + LocalDate.now();
String cached = redisTemplate.opsForValue().get(cacheKey); String cached = redisTemplate.opsForValue().get(cacheKey);
if (cached != null) { if (cached != null) {
log.debug("推荐缓存命中: userId={}", userId); log.debug("推荐缓存命中: userId={}", userId);
return parseCachedRecommendations(cached); return parseCachedRecommendations(cached).stream()
.filter(item -> isStillEligible(userId, item.getUserId()))
.limit(matchConfig.getDefaultDailyLimit())
.toList();
} }
// 2. 排除已Pass冷却期的用户 // 2. 排除已Pass冷却期的用户
@@ -106,6 +117,9 @@ public class RecommendationEngineService {
* 双重校验:推荐二次 Eligibility(防止缓存与实时状态不一致) * 双重校验:推荐二次 Eligibility(防止缓存与实时状态不一致)
*/ */
public boolean isStillEligible(String userId, String targetUserId) { 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); UserProfile target = userProfileMapper.findByUserId(targetUserId).orElse(null);
if (target == null) return false; if (target == null) return false;
@@ -123,6 +137,12 @@ public class RecommendationEngineService {
return false; 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; return true;
} }
@@ -178,15 +198,9 @@ public class RecommendationEngineService {
.collect(Collectors.toList()); .collect(Collectors.toList());
} }
// 过滤掉账号异常用户(banned/suspended/closed) // 最终实时资格过滤包含双向 Block、关系状态、推荐开关、隐私和可选本地资格门禁。
candidates = candidates.stream() candidates = candidates.stream()
.filter(p -> { .filter(p -> isStillEligible(userId, p.getUserId()))
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);
})
.collect(Collectors.toList()); .collect(Collectors.toList());
return candidates; return candidates;
@@ -227,8 +241,9 @@ public class RecommendationEngineService {
return userProfileMapper.findByUserId(userId).orElse(null); return userProfileMapper.findByUserId(userId).orElse(null);
} }
private boolean checkBidirectionalRequired(UserPreference myPref, UserProfile candidate, boolean checkBidirectionalRequired(UserPreference myPref, UserProfile candidate,
UserPreference targetPref, UserProfile myProfile) { UserPreference targetPref, UserProfile myProfile) {
if (myPref == null || candidate == null || targetPref == null || myProfile == null) return false;
// 1. 年龄 Required 检查 // 1. 年龄 Required 检查
if ("required".equals(myPref.getAgePriority()) && myPref.getMinAge() != null && myPref.getMaxAge() != null) { if ("required".equals(myPref.getAgePriority()) && myPref.getMinAge() != null && myPref.getMaxAge() != null) {
int candidateAge = calculateAge(candidate.getBirthDate()); int candidateAge = calculateAge(candidate.getBirthDate());
@@ -308,6 +323,24 @@ public class RecommendationEngineService {
return true; 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) { private double calculateScore(String fromUserId, UserProfile target, UserPreference pref) {
// TODO: Phase 07 后续完善评分逻辑 // TODO: Phase 07 后续完善评分逻辑
return 50.0; // V1 简化:默认中间分 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.entity.UserPhoto;
import com.danyang.match.platform.photo.mapper.UserPhotoMapper; import com.danyang.match.platform.photo.mapper.UserPhotoMapper;
import com.danyang.match.platform.shared.constant.Constants; import com.danyang.match.platform.shared.constant.Constants;
import com.danyang.match.platform.shared.config.FeatureFlagConfig;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
@@ -34,6 +35,7 @@ public class PhotoService {
private final UserPhotoMapper photoMapper; private final UserPhotoMapper photoMapper;
private final AmazonS3 amazonS3; private final AmazonS3 amazonS3;
private final FeatureFlagConfig featureFlags;
@Value("${object-storage.bucket-private}") @Value("${object-storage.bucket-private}")
private String privateBucket; private String privateBucket;
@@ -85,6 +87,15 @@ public class PhotoService {
return result; 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 @Transactional
public UserPhoto uploadAndSave(String userId, MultipartFile file, String photoType, int sortOrder) { public UserPhoto uploadAndSave(String userId, MultipartFile file, String photoType, int sortOrder) {
// 1. 安全校验 // 1. 安全校验
if ("local_eligibility".equals(photoType) && !isAllowedLocalEvidenceMimeType(file.getContentType())) {
throw new RuntimeException("不支持的佐证文件格式: " + file.getContentType());
}
if (!isAllowedMimeType(file.getContentType())) { if (!isAllowedMimeType(file.getContentType())) {
throw new RuntimeException("不支持的图片格式: " + file.getContentType()); throw new RuntimeException("不支持的图片格式: " + file.getContentType());
} }
if (!validateSize(file)) { if (!validateSize(file, photoType)) {
throw new RuntimeException("图片大小超过限制(最大10MB)"); throw new RuntimeException("图片大小超过限制(最大10MB)");
} }
InputStream stream; InputStream stream;
@@ -207,6 +221,24 @@ public class PhotoService {
return file.getSize() <= 10 * 1024 * 1024; 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) { 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) { 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.shared.security.principal.OperatorPrincipal;
import com.danyang.match.platform.review.dto.ReviewTaskDetailDTO; import com.danyang.match.platform.review.dto.ReviewTaskDetailDTO;
import com.danyang.match.platform.review.dto.ReviewResultDTO; 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.review.service.ReviewService;
import com.danyang.match.platform.shared.dto.ApiResponse; import com.danyang.match.platform.shared.dto.ApiResponse;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@@ -29,7 +30,7 @@ public class ReviewController {
* GET /api/v1/operator/reviews/stats * GET /api/v1/operator/reviews/stats
*/ */
@GetMapping @GetMapping
public ApiResponse<List<Map<String, Object>>> list( public ApiResponse<List<ReviewTask>> list(
@AuthenticationPrincipal OperatorPrincipal principal, @AuthenticationPrincipal OperatorPrincipal principal,
@RequestParam(defaultValue = "0") Integer page, @RequestParam(defaultValue = "0") Integer page,
@RequestParam(defaultValue = "20") Integer pageSize) { @RequestParam(defaultValue = "20") Integer pageSize) {
@@ -46,12 +47,11 @@ public class ReviewController {
* GET /api/v1/operator/reviews/pending * GET /api/v1/operator/reviews/pending
*/ */
@GetMapping("/pending") @GetMapping("/pending")
public ApiResponse<List<Map<String, Object>>> listPending( public ApiResponse<List<ReviewTask>> listPending(
@AuthenticationPrincipal OperatorPrincipal principal, @AuthenticationPrincipal OperatorPrincipal principal,
@RequestParam(defaultValue = "1") Integer page, @RequestParam(defaultValue = "1") Integer page,
@RequestParam(defaultValue = "20") Integer pageSize) { @RequestParam(defaultValue = "20") Integer pageSize) {
// TODO: Phase 05 后续补充完整分页 return ApiResponse.ok(reviewService.listPendingTasks(Math.max(0, page - 1), pageSize));
return ApiResponse.ok(List.of());
} }
/** /**
@@ -59,7 +59,7 @@ public class ReviewController {
* POST /api/v1/operator/reviews/{taskId}/claim * POST /api/v1/operator/reviews/{taskId}/claim
*/ */
@PostMapping("/{taskId}/claim") @PostMapping("/{taskId}/claim")
@PreAuthorize("hasAuthority('review.process')") @PreAuthorize("hasAnyAuthority('review.process', 'local_eligibility.review')")
public ApiResponse<Map<String, Object>> claimTask( public ApiResponse<Map<String, Object>> claimTask(
@AuthenticationPrincipal OperatorPrincipal principal, @AuthenticationPrincipal OperatorPrincipal principal,
@PathVariable String taskId) { @PathVariable String taskId) {
@@ -135,14 +135,51 @@ public class ReviewController {
return ApiResponse.ok(reviewService.reject(taskId, operatorId, comment, reasonCodes)); 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 * GET /api/v1/operator/reviews/reasons
*/ */
@GetMapping("/reasons") @GetMapping("/reasons")
public ApiResponse<List<Map<String, String>>> getReasons() { public ApiResponse<List<Map<String, String>>> getReasons() {
// TODO: Phase 05 后续补充从数据库加载 return ApiResponse.ok(reviewService.getReasonOptions());
return ApiResponse.ok(List.of());
} }
} }
@@ -25,6 +25,7 @@ public class ReviewTaskDetailDTO {
private Map<String, Object> userPreference; private Map<String, Object> userPreference;
private List<Map<String, Object>> photos; private List<Map<String, Object>> photos;
private List<Map<String, Object>> reviewItems; private List<Map<String, Object>> reviewItems;
private List<Map<String, Object>> localEligibilityEvidences;
// 审核原因选项 // 审核原因选项
private List<Map<String, String>> reasonOptions; 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.Mapper;
import org.apache.ibatis.annotations.ResultMap; import org.apache.ibatis.annotations.ResultMap;
import org.apache.ibatis.annotations.Select; 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.List;
import java.util.Optional; import java.util.Optional;
@@ -12,6 +14,13 @@ import java.util.Optional;
@Mapper @Mapper
public interface ReviewTaskMapper extends BaseMapper<ReviewTask> { 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") @Select("SELECT * FROM review_tasks WHERE status = 'pending' ORDER BY submitted_at ASC LIMIT 1 FOR UPDATE SKIP LOCKED")
Optional<ReviewTask> findNextPending(); 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.AuditLogMapper;
import com.danyang.match.platform.audit.mapper.SensitiveAccessLogMapper; 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.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.local_eligibility.mapper.LocalEligibilityMapper;
import com.danyang.match.platform.notification.service.NotificationService; 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.dto.PublicUserProfileDTO;
import com.danyang.match.platform.profile.mapper.UserProfileMapper; import com.danyang.match.platform.profile.mapper.UserProfileMapper;
import com.danyang.match.platform.review.dto.ReviewTaskDetailDTO; import com.danyang.match.platform.review.dto.ReviewTaskDetailDTO;
@@ -40,8 +43,10 @@ public class ReviewService {
private final UserProfileMapper userProfileMapper; private final UserProfileMapper userProfileMapper;
private final AuditLogMapper auditLogMapper; private final AuditLogMapper auditLogMapper;
private final LocalEligibilityMapper localEligibilityMapper; private final LocalEligibilityMapper localEligibilityMapper;
private final LocalEligibilityEvidenceMapper localEligibilityEvidenceMapper;
private final SensitiveAccessLogMapper sensitiveAccessLogMapper; private final SensitiveAccessLogMapper sensitiveAccessLogMapper;
private final NotificationService notificationService; private final NotificationService notificationService;
private final PhotoService photoService;
/** /**
* 领取审核任务(原子操作,防并发) * 领取审核任务(原子操作,防并发)
@@ -50,17 +55,7 @@ public class ReviewService {
@Transactional @Transactional
public Optional<ReviewTask> claimTask(String operatorId, String taskId) { public Optional<ReviewTask> claimTask(String operatorId, String taskId) {
// 先查找任务 // 先查找任务
ReviewTask task = new ReviewTask(); int rows = reviewTaskMapper.claimTaskAtomic(taskId, operatorId, Instant.now());
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()));
if (rows == 0) { if (rows == 0) {
return Optional.empty(); // 已被其他审核员领取 return Optional.empty(); // 已被其他审核员领取
@@ -93,6 +88,12 @@ public class ReviewService {
return stats; 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())) .map(r -> Map.of("code", r.getCode(), "name", r.getName()))
.toList()); .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; return dto;
} }
@@ -325,6 +344,7 @@ public class ReviewService {
if (!Constants.REVIEW_TYPE_LOCAL_ELIGIBILITY.equals(task.getReviewType())) { if (!Constants.REVIEW_TYPE_LOCAL_ELIGIBILITY.equals(task.getReviewType())) {
throw new RuntimeException("该任务不是本地资格审核"); throw new RuntimeException("该任务不是本地资格审核");
} }
requireClaimOwner(task, operatorId);
task.setStatus(Constants.REVIEW_STATUS_NEED_EDIT); task.setStatus(Constants.REVIEW_STATUS_NEED_EDIT);
task.setFinalComment(comment); task.setFinalComment(comment);
@@ -332,15 +352,10 @@ public class ReviewService {
reviewTaskMapper.updateById(task); reviewTaskMapper.updateById(task);
// 更新本地资格状态 // 更新本地资格状态
LocalEligibility el = localEligibilityMapper.selectOne( LocalEligibility el = requireLocalEligibility(task.getUserId());
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<LocalEligibility>() el.setVerificationStatus("needs_evidence");
.eq(LocalEligibility::getUserId, task.getUserId()) el.setReviewTaskId(null);
); localEligibilityMapper.updateById(el);
if (el != null) {
el.setVerificationStatus("needs_evidence");
el.setReviewTaskId(null);
localEligibilityMapper.updateById(el);
}
saveAuditLog(operatorId, "NEED_EVIDENCE", "local_eligibility_review", taskId, null, comment); saveAuditLog(operatorId, "NEED_EVIDENCE", "local_eligibility_review", taskId, null, comment);
// V1.1: 通知用户需补充佐证 // V1.1: 通知用户需补充佐证
@@ -371,6 +386,7 @@ public class ReviewService {
if (!Constants.REVIEW_TYPE_LOCAL_ELIGIBILITY.equals(task.getReviewType())) { if (!Constants.REVIEW_TYPE_LOCAL_ELIGIBILITY.equals(task.getReviewType())) {
throw new RuntimeException("该任务不是本地资格审核"); throw new RuntimeException("该任务不是本地资格审核");
} }
requireClaimOwner(task, operatorId);
task.setStatus(Constants.REVIEW_STATUS_APPROVED); task.setStatus(Constants.REVIEW_STATUS_APPROVED);
task.setFinalComment(comment); task.setFinalComment(comment);
@@ -378,17 +394,12 @@ public class ReviewService {
reviewTaskMapper.updateById(task); reviewTaskMapper.updateById(task);
// 更新本地资格状态 // 更新本地资格状态
LocalEligibility el = localEligibilityMapper.selectOne( LocalEligibility el = requireLocalEligibility(task.getUserId());
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<LocalEligibility>() el.setVerificationStatus("verified");
.eq(LocalEligibility::getUserId, task.getUserId()) el.setReviewTaskId(taskId);
); el.setVerifiedAt(Instant.now());
if (el != null) { el.setVerifiedBy(operatorId);
el.setVerificationStatus("verified"); localEligibilityMapper.updateById(el);
el.setReviewTaskId(taskId);
el.setVerifiedAt(Instant.now());
el.setVerifiedBy(operatorId);
localEligibilityMapper.updateById(el);
}
saveAuditLog(operatorId, "APPROVE_LOCAL_ELIGIBILITY", "local_eligibility_review", taskId, null, comment); saveAuditLog(operatorId, "APPROVE_LOCAL_ELIGIBILITY", "local_eligibility_review", taskId, null, comment);
// V1.1: 通知用户本地资格已通过核验 // V1.1: 通知用户本地资格已通过核验
@@ -419,6 +430,7 @@ public class ReviewService {
if (!Constants.REVIEW_TYPE_LOCAL_ELIGIBILITY.equals(task.getReviewType())) { if (!Constants.REVIEW_TYPE_LOCAL_ELIGIBILITY.equals(task.getReviewType())) {
throw new RuntimeException("该任务不是本地资格审核"); throw new RuntimeException("该任务不是本地资格审核");
} }
requireClaimOwner(task, operatorId);
task.setStatus(Constants.REVIEW_STATUS_REJECTED); task.setStatus(Constants.REVIEW_STATUS_REJECTED);
task.setFinalComment(comment); task.setFinalComment(comment);
@@ -426,17 +438,12 @@ public class ReviewService {
reviewTaskMapper.updateById(task); reviewTaskMapper.updateById(task);
// 更新本地资格状态 // 更新本地资格状态
LocalEligibility el = localEligibilityMapper.selectOne( LocalEligibility el = requireLocalEligibility(task.getUserId());
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<LocalEligibility>() el.setVerificationStatus("rejected");
.eq(LocalEligibility::getUserId, task.getUserId()) el.setReviewTaskId(taskId);
); el.setVerifiedAt(Instant.now());
if (el != null) { el.setVerifiedBy(operatorId);
el.setVerificationStatus("rejected"); localEligibilityMapper.updateById(el);
el.setReviewTaskId(taskId);
el.setVerifiedAt(Instant.now());
el.setVerifiedBy(operatorId);
localEligibilityMapper.updateById(el);
}
saveAuditLog(operatorId, "REJECT_LOCAL_ELIGIBILITY", "local_eligibility_review", taskId, null, comment); saveAuditLog(operatorId, "REJECT_LOCAL_ELIGIBILITY", "local_eligibility_review", taskId, null, comment);
// V1.1: 通知用户本地资格未通过 // V1.1: 通知用户本地资格未通过
@@ -456,16 +463,47 @@ public class ReviewService {
* 记录佐证文件访问审计(INV-011) * 记录佐证文件访问审计(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(); SensitiveAccessLog logEntry = new SensitiveAccessLog();
logEntry.setOperatorId(operatorId); logEntry.setOperatorId(operatorId);
logEntry.setUserId(userId); logEntry.setUserId(userId);
logEntry.setDataType(Constants.SENSITIVE_DATA_TYPE_LOCAL_EVIDENCE); logEntry.setDataType(Constants.SENSITIVE_DATA_TYPE_LOCAL_EVIDENCE);
logEntry.setReason(reason != null ? reason : "本地资格审核"); logEntry.setReason(reason != null ? reason : "本地资格审核");
logEntry.setAccessTarget(evidenceId);
logEntry.setAccessResult(result);
sensitiveAccessLogMapper.insert(logEntry); sensitiveAccessLogMapper.insert(logEntry);
log.info("佐证文件访问审计: operatorId={}, userId={}, evidenceId={}", operatorId, userId, evidenceId); 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) { private void updateProfileStatus(String userId, String status) {
userProfileMapper.update(null, userProfileMapper.update(null,
new LambdaUpdateWrapper<com.danyang.match.platform.profile.entity.UserProfile>() new LambdaUpdateWrapper<com.danyang.match.platform.profile.entity.UserProfile>()
@@ -47,7 +47,9 @@ public class FeatureFlagConfig {
/** 单个文件最大大小(MB) */ /** 单个文件最大大小(MB) */
private int localEvidenceMaxFileSizeMB = 10; private int localEvidenceMaxFileSizeMB = 10;
/** 允许的文件类型 */ /** 允许的文件类型 */
private String localEvidenceAllowedTypes = "jpg,png,pdf"; private String localEvidenceAllowedTypes = "jpg,png";
/** 佐证材料保留天数;实际清理由运维任务按此配置执行 */
private int localEvidenceRetentionDays = 365;
// ---- V1.1 咨询附件私存储 ---- // ---- V1.1 咨询附件私存储 ----
/** 咨询附件是否使用私有存储桶 */ /** 咨询附件是否使用私有存储桶 */
@@ -111,6 +113,9 @@ public class FeatureFlagConfig {
public String getLocalEvidenceAllowedTypes() { return localEvidenceAllowedTypes; } public String getLocalEvidenceAllowedTypes() { return localEvidenceAllowedTypes; }
public void setLocalEvidenceAllowedTypes(String localEvidenceAllowedTypes) { this.localEvidenceAllowedTypes = 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 boolean isCounselingAttachmentsPrivate() { return counselingAttachmentsPrivate; }
public void setCounselingAttachmentsPrivate(boolean counselingAttachmentsPrivate) { this.counselingAttachmentsPrivate = counselingAttachmentsPrivate; } public void setCounselingAttachmentsPrivate(boolean counselingAttachmentsPrivate) { this.counselingAttachmentsPrivate = counselingAttachmentsPrivate; }
@@ -140,6 +145,7 @@ public class FeatureFlagConfig {
flags.put("localEvidenceMaxFiles", localEvidenceMaxFiles); flags.put("localEvidenceMaxFiles", localEvidenceMaxFiles);
flags.put("localEvidenceMaxFileSizeMB", localEvidenceMaxFileSizeMB); flags.put("localEvidenceMaxFileSizeMB", localEvidenceMaxFileSizeMB);
flags.put("localEvidenceAllowedTypes", localEvidenceAllowedTypes); flags.put("localEvidenceAllowedTypes", localEvidenceAllowedTypes);
flags.put("localEvidenceRetentionDays", localEvidenceRetentionDays);
flags.put("counselingAttachmentsPrivate", counselingAttachmentsPrivate); flags.put("counselingAttachmentsPrivate", counselingAttachmentsPrivate);
flags.put("counselingAttachmentsBucket", counselingAttachmentsBucket); flags.put("counselingAttachmentsBucket", counselingAttachmentsBucket);
return flags; return flags;
@@ -7,7 +7,7 @@ import static org.junit.jupiter.api.Assertions.*;
class PhotoServiceTest { class PhotoServiceTest {
private final PhotoService photoService = new PhotoService(null, null); private final PhotoService photoService = new PhotoService(null, null, null);
@Test @Test
void testValidateMagicNumberJpeg() { void testValidateMagicNumberJpeg() {