diff --git a/admin-web/src/modules/review/ReviewView.vue b/admin-web/src/modules/review/ReviewView.vue index 12a587f..09650d6 100644 --- a/admin-web/src/modules/review/ReviewView.vue +++ b/admin-web/src/modules/review/ReviewView.vue @@ -32,7 +32,7 @@ - 领取审核 + 领取审核 只读 @@ -85,7 +85,24 @@ - + + 本地资格佐证 + + + + + {{ formatTime(row.createdAt) }} + + + + 查看 + 无权限 + + + + + + 审核操作 @@ -98,7 +115,7 @@ 通过 - 需修改 + {{ currentTask.reviewType === 'local_eligibility' ? '补充佐证' : '需修改' }} 拒绝 @@ -127,6 +144,16 @@ const currentTask = ref(null) const reasonOptions = ref([]) 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) : '-' } diff --git a/backend/src/main/java/com/danyang/match/platform/audit/entity/SensitiveAccessLog.java b/backend/src/main/java/com/danyang/match/platform/audit/entity/SensitiveAccessLog.java index 92f3ad1..cdf3b76 100644 --- a/backend/src/main/java/com/danyang/match/platform/audit/entity/SensitiveAccessLog.java +++ b/backend/src/main/java/com/danyang/match/platform/audit/entity/SensitiveAccessLog.java @@ -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; } diff --git a/backend/src/main/java/com/danyang/match/platform/home/dto/HomeDTO.java b/backend/src/main/java/com/danyang/match/platform/home/dto/HomeDTO.java index d2220c4..000dbb9 100644 --- a/backend/src/main/java/com/danyang/match/platform/home/dto/HomeDTO.java +++ b/backend/src/main/java/com/danyang/match/platform/home/dto/HomeDTO.java @@ -74,6 +74,7 @@ public class HomeDTO { private String label; private String icon; private boolean enabled; + private String disabledReason; private String targetPage; } diff --git a/backend/src/main/java/com/danyang/match/platform/home/service/HomeApplicationService.java b/backend/src/main/java/com/danyang/match/platform/home/service/HomeApplicationService.java index af9d803..6dd4179 100644 --- a/backend/src/main/java/com/danyang/match/platform/home/service/HomeApplicationService.java +++ b/backend/src/main/java/com/danyang/match/platform/home/service/HomeApplicationService.java @@ -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 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 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() + .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 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; } diff --git a/backend/src/main/java/com/danyang/match/platform/local_eligibility/entity/LocalEligibilityEvidence.java b/backend/src/main/java/com/danyang/match/platform/local_eligibility/entity/LocalEligibilityEvidence.java index 6367773..b398759 100644 --- a/backend/src/main/java/com/danyang/match/platform/local_eligibility/entity/LocalEligibilityEvidence.java +++ b/backend/src/main/java/com/danyang/match/platform/local_eligibility/entity/LocalEligibilityEvidence.java @@ -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; diff --git a/backend/src/main/java/com/danyang/match/platform/local_eligibility/service/LocalEligibilityService.java b/backend/src/main/java/com/danyang/match/platform/local_eligibility/service/LocalEligibilityService.java index 695a08a..10441c3 100644 --- a/backend/src/main/java/com/danyang/match/platform/local_eligibility/service/LocalEligibilityService.java +++ b/backend/src/main/java/com/danyang/match/platform/local_eligibility/service/LocalEligibilityService.java @@ -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 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().eq(LocalEligibility::getUserId, userId)); if (el == null) { throw new IllegalStateException("请先声明本地资格"); } + long evidenceCount = evidenceMapper.selectCount( + new LambdaQueryWrapper() + .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()); diff --git a/backend/src/main/java/com/danyang/match/platform/match/service/MatchEligibilityService.java b/backend/src/main/java/com/danyang/match/platform/match/service/MatchEligibilityService.java index f5d9238..3dc822c 100644 --- a/backend/src/main/java/com/danyang/match/platform/match/service/MatchEligibilityService.java +++ b/backend/src/main/java/com/danyang/match/platform/match/service/MatchEligibilityService.java @@ -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()); } /** diff --git a/backend/src/main/java/com/danyang/match/platform/match/service/RecommendationEngineService.java b/backend/src/main/java/com/danyang/match/platform/match/service/RecommendationEngineService.java index e81e2cc..df30624 100644 --- a/backend/src/main/java/com/danyang/match/platform/match/service/RecommendationEngineService.java +++ b/backend/src/main/java/com/danyang/match/platform/match/service/RecommendationEngineService.java @@ -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 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 简化:默认中间分 diff --git a/backend/src/main/java/com/danyang/match/platform/photo/service/PhotoService.java b/backend/src/main/java/com/danyang/match/platform/photo/service/PhotoService.java index b2595a4..ff19beb 100644 --- a/backend/src/main/java/com/danyang/match/platform/photo/service/PhotoService.java +++ b/backend/src/main/java/com/danyang/match/platform/photo/service/PhotoService.java @@ -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 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); + } + /** * 软删除照片 */ diff --git a/backend/src/main/java/com/danyang/match/platform/relationship/service/RelationshipService.java b/backend/src/main/java/com/danyang/match/platform/relationship/service/RelationshipService.java index 1622f99..0dd14b6 100644 --- a/backend/src/main/java/com/danyang/match/platform/relationship/service/RelationshipService.java +++ b/backend/src/main/java/com/danyang/match/platform/relationship/service/RelationshipService.java @@ -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) { diff --git a/backend/src/main/java/com/danyang/match/platform/review/controller/ReviewController.java b/backend/src/main/java/com/danyang/match/platform/review/controller/ReviewController.java index c47e92a..dfed119 100644 --- a/backend/src/main/java/com/danyang/match/platform/review/controller/ReviewController.java +++ b/backend/src/main/java/com/danyang/match/platform/review/controller/ReviewController.java @@ -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( + public ApiResponse> 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>> listPending( + public ApiResponse> 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> 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 needEvidence( + @AuthenticationPrincipal OperatorPrincipal principal, + @PathVariable String taskId, + @RequestBody Map body) { + return ApiResponse.ok(reviewService.needEvidence(taskId, principal.id(), body.get("comment"))); + } + + @PostMapping("/{taskId}/approve-local-eligibility") + @PreAuthorize("hasAuthority('local_eligibility.review')") + public ApiResponse approveLocalEligibility( + @AuthenticationPrincipal OperatorPrincipal principal, + @PathVariable String taskId, + @RequestBody Map body) { + return ApiResponse.ok(reviewService.approveLocalEligibility(taskId, principal.id(), body.get("comment"))); + } + + @PostMapping("/{taskId}/reject-local-eligibility") + @PreAuthorize("hasAuthority('local_eligibility.review')") + public ApiResponse rejectLocalEligibility( + @AuthenticationPrincipal OperatorPrincipal principal, + @PathVariable String taskId, + @RequestBody Map 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> accessEvidence( + @AuthenticationPrincipal OperatorPrincipal principal, + @PathVariable String taskId, + @PathVariable String evidenceId, + @RequestBody Map body) { + return ApiResponse.ok(reviewService.accessLocalEligibilityEvidence( + taskId, evidenceId, principal.id(), body.get("purpose"))); + } + /** * 获取审核原因选项 * GET /api/v1/operator/reviews/reasons */ @GetMapping("/reasons") public ApiResponse>> getReasons() { - // TODO: Phase 05 后续补充从数据库加载 - return ApiResponse.ok(List.of()); + return ApiResponse.ok(reviewService.getReasonOptions()); } } diff --git a/backend/src/main/java/com/danyang/match/platform/review/dto/ReviewTaskDetailDTO.java b/backend/src/main/java/com/danyang/match/platform/review/dto/ReviewTaskDetailDTO.java index be09c4c..d7fbbac 100644 --- a/backend/src/main/java/com/danyang/match/platform/review/dto/ReviewTaskDetailDTO.java +++ b/backend/src/main/java/com/danyang/match/platform/review/dto/ReviewTaskDetailDTO.java @@ -25,6 +25,7 @@ public class ReviewTaskDetailDTO { private Map userPreference; private List> photos; private List> reviewItems; + private List> localEligibilityEvidences; // 审核原因选项 private List> reasonOptions; diff --git a/backend/src/main/java/com/danyang/match/platform/review/mapper/ReviewTaskMapper.java b/backend/src/main/java/com/danyang/match/platform/review/mapper/ReviewTaskMapper.java index 41224f0..c1fe8ea 100644 --- a/backend/src/main/java/com/danyang/match/platform/review/mapper/ReviewTaskMapper.java +++ b/backend/src/main/java/com/danyang/match/platform/review/mapper/ReviewTaskMapper.java @@ -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 { + @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 findNextPending(); diff --git a/backend/src/main/java/com/danyang/match/platform/review/service/ReviewService.java b/backend/src/main/java/com/danyang/match/platform/review/service/ReviewService.java index 01a3a24..505405c 100644 --- a/backend/src/main/java/com/danyang/match/platform/review/service/ReviewService.java +++ b/backend/src/main/java/com/danyang/match/platform/review/service/ReviewService.java @@ -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 claimTask(String operatorId, String taskId) { // 先查找任务 - ReviewTask task = new ReviewTask(); - task.setId(taskId); - - // 原子更新:只有 status = pending 时才能领取 - int rows = reviewTaskMapper.update(null, - new LambdaUpdateWrapper() - .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> 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 evidences = eligibility == null ? List.of() + : localEligibilityEvidenceMapper.selectList( + new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper() + .eq(LocalEligibilityEvidence::getLocalEligibilityId, eligibility.getId()) + .isNull(LocalEligibilityEvidence::getDeletedAt) + .orderByAsc(LocalEligibilityEvidence::getCreatedAt)); + dto.setLocalEligibilityEvidences(evidences.stream().map(e -> { + Map 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() - .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() - .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() - .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 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 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() + .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() diff --git a/backend/src/main/java/com/danyang/match/platform/shared/config/FeatureFlagConfig.java b/backend/src/main/java/com/danyang/match/platform/shared/config/FeatureFlagConfig.java index e69cfb7..e050dab 100644 --- a/backend/src/main/java/com/danyang/match/platform/shared/config/FeatureFlagConfig.java +++ b/backend/src/main/java/com/danyang/match/platform/shared/config/FeatureFlagConfig.java @@ -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; diff --git a/backend/src/test/java/com/danyang/match/platform/photo/PhotoServiceTest.java b/backend/src/test/java/com/danyang/match/platform/photo/PhotoServiceTest.java index 9f1ba59..d2b1f1b 100644 --- a/backend/src/test/java/com/danyang/match/platform/photo/PhotoServiceTest.java +++ b/backend/src/test/java/com/danyang/match/platform/photo/PhotoServiceTest.java @@ -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() {