feat: enhance home aggregate, review atomicity, local eligibility and match engine (phase 04-07)
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+76
-32
@@ -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;
|
||||
}
|
||||
|
||||
+2
@@ -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;
|
||||
|
||||
+32
-1
@@ -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());
|
||||
|
||||
+1
-2
@@ -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());
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+44
-11
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 软删除照片
|
||||
*/
|
||||
|
||||
+2
-1
@@ -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) {
|
||||
|
||||
+44
-7
@@ -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();
|
||||
|
||||
|
||||
+99
-43
@@ -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>()
|
||||
|
||||
+7
-1
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user