V1.0.1 配置与规则纠偏:照片上限配置化+强校验 / 活体开关闭环 / 推荐阈值快照+规则版本化 / governance 三表+漂移审计
根治三类工程治理问题(技术默认值≠产品规则、配置存在但未闭环、文档代码漂移): - PROFILE-001:profile.photo.maxCount(默认6未确认)+ ProfilePolicy + 服务器端 FOR UPDATE 并发强校验(PHOTO_LIMIT_EXCEEDED,拒绝先于 S3 不留孤儿)+ 前端 mergeAppConfig 嵌套键修复 - CFG-001/IDENTITY-001:IdentityPolicy + GET /verification/liveness-status 真实判定点 (默认关不阻断,开则 enhancedVerified 变化,消除假开关)+ 删死 seed livenessVerification.enabled - MATCH-001:阈值60定为技术默认(消解 PENDING 矛盾)+ V050 recommendation_records.threshold_snapshot 规则版本化 + 删死重复配置类 MatchConfig - DOC-001:新增 docs/governance/(config-register/open-questions/decision-register/ hardcoded-rule-audit/config-drift-audit) - TEST-001:新增守卫 PhotoLimitGuardTest(8)/IdentityLivenessGuardTest(5)/ RecommendationThresholdSnapshotGuardTest(4),均经负向注入可红 全量回归 733/0/0/3(基线717+17新增-1删死测试);UI 契约门禁 18/18;STATUS 升 rc.21
This commit is contained in:
@@ -1,5 +1,8 @@
|
||||
package com.danyang.match.platform.shared.dto;
|
||||
|
||||
import com.danyang.match.platform.shared.policy.MatchingPolicy;
|
||||
import com.danyang.match.platform.shared.policy.ProfilePolicy;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.HashMap;
|
||||
@@ -11,8 +14,12 @@ import java.util.Map;
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/app")
|
||||
@RequiredArgsConstructor
|
||||
public class AppController {
|
||||
|
||||
private final ProfilePolicy profilePolicy;
|
||||
private final MatchingPolicy matchingPolicy;
|
||||
|
||||
/**
|
||||
* 获取应用配置
|
||||
* GET /api/v1/app/config
|
||||
@@ -36,13 +43,17 @@ public class AppController {
|
||||
config.put("features", features);
|
||||
|
||||
// 业务限制参数
|
||||
// 注:maxProfilePhotos / matchScoreThreshold 改由统一配置源(ProfilePolicy / MatchingPolicy)
|
||||
// 下发,禁止在此写死(PROFILE-001 / MATCH-001)。其余项(dailyRecommendationLimit、
|
||||
// maxInterestTags、selfIntroMaxLength、recommendationCooldownDays)本轮未纳入,
|
||||
// 仍为硬编码,已在 config-drift-audit 中登记为「待统一」未来项。
|
||||
Map<String, Object> businessLimits = new HashMap<>();
|
||||
businessLimits.put("dailyRecommendationLimit", 6);
|
||||
businessLimits.put("maxProfilePhotos", 6);
|
||||
businessLimits.put("maxProfilePhotos", profilePolicy.getMaxPhotoCount());
|
||||
businessLimits.put("maxInterestTags", 8);
|
||||
businessLimits.put("selfIntroMaxLength", 500);
|
||||
businessLimits.put("recommendationCooldownDays", 180);
|
||||
businessLimits.put("matchScoreThreshold", 60);
|
||||
businessLimits.put("matchScoreThreshold", matchingPolicy.getRecommendationMinScore());
|
||||
config.put("businessLimits", businessLimits);
|
||||
|
||||
// 审核相关
|
||||
|
||||
+5
@@ -20,4 +20,9 @@ public class RecommendationRecord extends BaseEntity {
|
||||
private String action; // liked, passed, blocked, matched
|
||||
private java.time.Instant actionAt;
|
||||
private Integer ruleVersion;
|
||||
/**
|
||||
* 推荐判定当次所用的最低评分阈值快照(V050,MATCH-001)。
|
||||
* 配合 {@link #ruleVersion} 追溯历史推荐依据的规则版本;历史行为 null。
|
||||
*/
|
||||
private Integer thresholdSnapshot;
|
||||
}
|
||||
|
||||
+2
@@ -386,6 +386,8 @@ public class RecommendationEngineService {
|
||||
record.setReasons(String.join(";", reasons));
|
||||
record.setShownAt(Instant.now());
|
||||
record.setRuleVersion(matchConfig.getRuleVersion());
|
||||
// MATCH-001:快照当次判定所用阈值(配合 rule_version 追溯历史推荐依据的规则版本)
|
||||
record.setThresholdSnapshot(matchConfig.getDefaultScoreThreshold());
|
||||
recommendationRecordMapper.insert(record);
|
||||
}
|
||||
|
||||
|
||||
+2
@@ -64,6 +64,8 @@ public class RecommendationService {
|
||||
record.setReasons(new java.util.ArrayList<>(reasons).toString());
|
||||
record.setShownAt(Instant.now());
|
||||
record.setRuleVersion(matchConfig.getRuleVersion());
|
||||
// MATCH-001:快照当次判定所用阈值(配合 rule_version 追溯历史推荐依据的规则版本)
|
||||
record.setThresholdSnapshot(matchConfig.getDefaultScoreThreshold());
|
||||
recommendationRecordMapper.insert(record);
|
||||
log.debug("推荐记录: userId={}, targetUserId={}, level={}", userId, targetUserId, level);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.danyang.match.platform.photo.mapper;
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.danyang.match.platform.photo.entity.UserPhoto;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
import java.util.List;
|
||||
@@ -15,4 +16,18 @@ public interface UserPhotoMapper extends BaseMapper<UserPhoto> {
|
||||
|
||||
@Select("SELECT * FROM user_photos WHERE user_id = #{userId} AND photo_type = 'avatar' AND deleted_at IS NULL LIMIT 1")
|
||||
UserPhoto findAvatar(String userId);
|
||||
|
||||
/**
|
||||
* 锁定并统计该用户当前 active 的个人照片(头像 + 生活照,未软删)。
|
||||
*
|
||||
* <p>用 {@code SELECT ... FOR UPDATE} 锁住这些行,使同一用户的并发上传被串行化 ——
|
||||
* 先到的事务提交后,后到的事务在锁上等待、再重新读到最新数量(READ COMMITTED 下
|
||||
* FOR UPDATE 会做锁后复核),从而「并发上传不可突破上限」。
|
||||
* 参照 {@code ReviewTaskMapper} 的 {@code FOR UPDATE SKIP LOCKED} 先例(全代码库唯一
|
||||
* 悲观锁用法)。仅统计头像 + 生活照两类个人照片,本地资格佐证(local_eligibility)
|
||||
* 另有独立上限,不计入。
|
||||
*/
|
||||
@Select("SELECT id FROM user_photos WHERE user_id = #{userId} AND deleted_at IS NULL "
|
||||
+ "AND photo_type IN ('avatar','lifestyle') FOR UPDATE")
|
||||
List<String> lockActiveProfilePhotoIds(@Param("userId") String userId);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import com.danyang.match.platform.shared.constant.Constants;
|
||||
import com.danyang.match.platform.shared.config.FeatureFlagConfig;
|
||||
import com.danyang.match.platform.shared.enums.ApiResponseCode;
|
||||
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler;
|
||||
import com.danyang.match.platform.shared.policy.ProfilePolicy;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
@@ -38,6 +39,7 @@ public class PhotoService {
|
||||
private final UserPhotoMapper photoMapper;
|
||||
private final AmazonS3 amazonS3;
|
||||
private final FeatureFlagConfig featureFlags;
|
||||
private final ProfilePolicy profilePolicy;
|
||||
|
||||
@Value("${object-storage.bucket-private}")
|
||||
private String privateBucket;
|
||||
@@ -98,11 +100,44 @@ public class PhotoService {
|
||||
return Map.of("url", amazonS3.generatePresignedUrl(request).toString(), "expiresIn", 600);
|
||||
}
|
||||
|
||||
/**
|
||||
* 个人照片数量上限校验(服务器端强制,前端提示不可绕过)。
|
||||
*
|
||||
* <p>须在 {@code @Transactional} 事务内调用(调用方 {@link #savePhoto} /
|
||||
* {@link #uploadAndSave} 均已标注)。用 {@code SELECT ... FOR UPDATE} 锁定该用户
|
||||
* active 个人照片的行,使同用户并发上传被串行化:先提交的事务释放锁后,后到的事务
|
||||
* 锁上等待再重读最新数量,从而并发也不可突破上限(PHOTO-004)。
|
||||
*
|
||||
* <p>规则(PROFILE-001 / OQ-PROFILE-001):个人照片(头像 + 生活照)合计须 ≤
|
||||
* {@code profile.photo.maxCount}(当前技术默认 6,未获产品正式确认,见 config-register)。
|
||||
* <ul>
|
||||
* <li>lifestyle 新增 +1:当前 active 数 ≥ 上限即拒(PHOTO-002);</li>
|
||||
* <li>avatar 为槽位替换(旧头像软删后插新,总量不变):仅在已超限时拒;</li>
|
||||
* <li>local_eligibility 等佐证类型各有独立上限(localEvidenceMaxFiles),不在此计。</li>
|
||||
* </ul>
|
||||
*/
|
||||
public void ensureWithinPhotoLimit(String userId, String photoType) {
|
||||
if (!"avatar".equals(photoType) && !"lifestyle".equals(photoType)) {
|
||||
return; // 非个人照片类型(佐证等)走各自上限,不在个人照片上限约束内
|
||||
}
|
||||
int maxCount = profilePolicy.getMaxPhotoCount();
|
||||
int active = photoMapper.lockActiveProfilePhotoIds(userId).size();
|
||||
boolean willExceed = "lifestyle".equals(photoType) ? active >= maxCount : active > maxCount;
|
||||
if (willExceed) {
|
||||
throw new GlobalExceptionHandler.BusinessException(
|
||||
com.danyang.match.platform.shared.enums.ApiResponseCode.PHOTO_LIMIT_EXCEEDED,
|
||||
"照片数量已达到当前允许上限");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 直接保存照片记录(后端直传场景)
|
||||
*/
|
||||
@Transactional
|
||||
public UserPhoto savePhoto(String userId, String fileKey, String photoType, int sortOrder) {
|
||||
// 数量上限校验:所有 insert 路径统一在此强制(并发安全,见 ensureWithinPhotoLimit)
|
||||
ensureWithinPhotoLimit(userId, photoType);
|
||||
|
||||
// 如果是头像,先清除旧头像
|
||||
if ("avatar".equals(photoType)) {
|
||||
photoMapper.update(null, new com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper<UserPhoto>()
|
||||
@@ -157,6 +192,12 @@ public class PhotoService {
|
||||
}
|
||||
}
|
||||
|
||||
// 2.5 数量上限校验:必须放在对象存储上传之前 —— 超限若先传 S3 再在入库时拒绝,
|
||||
// 会留下无人引用的孤儿对象。本地资格佐证不在个人照片上限内(isLocalEvidence 跳过)。
|
||||
if (!isLocalEvidence) {
|
||||
ensureWithinPhotoLimit(userId, photoType);
|
||||
}
|
||||
|
||||
// 3. 生成 key 并上传(使用全新输入流,与校验流隔离,互不干扰)
|
||||
String fileKey = generateFileKey(userId, photoType, contentType);
|
||||
try {
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
package com.danyang.match.platform.shared.config;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* 系统配置属性
|
||||
* 所有未确认数量参数通过此类管理,不得硬编码为常量
|
||||
*/
|
||||
@Component
|
||||
@ConfigurationProperties(prefix = "match")
|
||||
public class MatchConfig {
|
||||
|
||||
/**
|
||||
* 每日推荐上限(待确认)
|
||||
*/
|
||||
private int defaultDailyLimit = 6;
|
||||
|
||||
/**
|
||||
* 推荐最低阈值分数(待确认)
|
||||
*/
|
||||
private int defaultScoreThreshold = 60;
|
||||
|
||||
/**
|
||||
* Pass 冷却天数(待确认)
|
||||
*/
|
||||
private int defaultCooldownDays = 180;
|
||||
|
||||
/**
|
||||
* 匹配规则版本号
|
||||
*/
|
||||
private int ruleVersion = 1;
|
||||
|
||||
public int getDefaultDailyLimit() {
|
||||
return defaultDailyLimit;
|
||||
}
|
||||
|
||||
public void setDefaultDailyLimit(int defaultDailyLimit) {
|
||||
this.defaultDailyLimit = defaultDailyLimit;
|
||||
}
|
||||
|
||||
public int getDefaultScoreThreshold() {
|
||||
return defaultScoreThreshold;
|
||||
}
|
||||
|
||||
public void setDefaultScoreThreshold(int defaultScoreThreshold) {
|
||||
this.defaultScoreThreshold = defaultScoreThreshold;
|
||||
}
|
||||
|
||||
public int getDefaultCooldownDays() {
|
||||
return defaultCooldownDays;
|
||||
}
|
||||
|
||||
public void setDefaultCooldownDays(int defaultCooldownDays) {
|
||||
this.defaultCooldownDays = defaultCooldownDays;
|
||||
}
|
||||
|
||||
public int getRuleVersion() {
|
||||
return ruleVersion;
|
||||
}
|
||||
|
||||
public void setRuleVersion(int ruleVersion) {
|
||||
this.ruleVersion = ruleVersion;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.danyang.match.platform.shared.config;
|
||||
|
||||
import lombok.Data;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* 个人资料(profile)配置。
|
||||
*
|
||||
* <p><b>配置化纪律</b>(AGENTS.md #12:未确认数量参数不得硬编码):照片上限
|
||||
* {@code profile.photo.maxCount} 当前技术默认 6,<b>并非产品正式确认值</b>(OQ-PROFILE-001)。
|
||||
* 只作为 {@code DEFAULT + CONFIG} 管理:可经 yml 覆盖,禁止散落在 Controller/Service/前端各自写死。
|
||||
* 服务器端强校验读取点见 {@code PhotoService.ensureWithinPhotoLimit},
|
||||
* 前端读取点见 {@code AppController#getConfig}({@code businessLimits.maxProfilePhotos})。
|
||||
* 不留「配了没人读」的死配置(Phase 22 T01~T05 教训)。
|
||||
*/
|
||||
@Component
|
||||
@ConfigurationProperties(prefix = "profile")
|
||||
public class ProfileProperties {
|
||||
|
||||
/** 个人资料子配置。 */
|
||||
private Photo photo = new Photo();
|
||||
|
||||
public Photo getPhoto() {
|
||||
return photo;
|
||||
}
|
||||
|
||||
public void setPhoto(Photo photo) {
|
||||
this.photo = photo;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class Photo {
|
||||
/**
|
||||
* 个人照片最大数量(头像 + 生活照合计上限)。
|
||||
* 技术默认 6,未获产品正式确认;改值无需改代码,且服务器端据此强制校验。
|
||||
*/
|
||||
private int maxCount = 6;
|
||||
}
|
||||
}
|
||||
@@ -43,7 +43,8 @@ public enum ApiResponseCode implements BaseEnum {
|
||||
CROSS_ORGANIZATION_DENIED("CROSS_ORGANIZATION_DENIED", "无权访问其他机构数据", HttpStatus.FORBIDDEN),
|
||||
OPERATION_NOT_ALLOWED("OPERATION_NOT_ALLOWED", "当前操作不允许", HttpStatus.FORBIDDEN),
|
||||
MATCHMAKER_CANNOT_LIKE("MATCHMAKER_CANNOT_LIKE", "红娘不能替用户操作喜欢", HttpStatus.FORBIDDEN),
|
||||
MATCH_SUCCESS_NEED_BOTH_CONFIRM("MATCH_SUCCESS_NEED_BOTH_CONFIRM", "成功牵手需要双方确认", HttpStatus.CONFLICT);
|
||||
MATCH_SUCCESS_NEED_BOTH_CONFIRM("MATCH_SUCCESS_NEED_BOTH_CONFIRM", "成功牵手需要双方确认", HttpStatus.CONFLICT),
|
||||
PHOTO_LIMIT_EXCEEDED("PHOTO_LIMIT_EXCEEDED", "照片数量已达到当前允许上限", HttpStatus.CONFLICT);
|
||||
|
||||
private final String code;
|
||||
private final String description;
|
||||
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
package com.danyang.match.platform.shared.policy;
|
||||
|
||||
import com.danyang.match.platform.shared.config.FeatureFlagConfig;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* {@link IdentityPolicy} 默认实现:从 Feature Flag({@code livenessEnabled} /
|
||||
* {@code livenessProvider})读取。
|
||||
*
|
||||
* <p>fail-safe:Flag 默认 false;配置缺失(无覆盖行)时沿用 Java 默认值,绝不自动开启。
|
||||
* 覆盖值优先级见 {@link FeatureFlagConfig}(数据库覆盖 > yml/环境变量 > 字段默认)。
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class DefaultIdentityPolicy implements IdentityPolicy {
|
||||
|
||||
private final FeatureFlagConfig featureFlagConfig;
|
||||
|
||||
@Override
|
||||
public boolean isLivenessRequired() {
|
||||
return featureFlagConfig.isLivenessEnabled();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getLivenessProvider() {
|
||||
return featureFlagConfig.getLivenessProvider();
|
||||
}
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
package com.danyang.match.platform.shared.policy;
|
||||
|
||||
import com.danyang.match.platform.match.config.MatchRuleConfigProperties;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* {@link MatchingPolicy} 默认实现:委托 {@code MatchRuleConfigProperties}
|
||||
* (yml 前缀 {@code match})读取当前生效阈值与规则版本。
|
||||
*
|
||||
* <p>本类是「统一读取入口」,不新增配置源 —— 阈值/版本仍以
|
||||
* {@code match.default-score-threshold} / {@code match.rule-version} 为唯一来源。
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class DefaultMatchingPolicy implements MatchingPolicy {
|
||||
|
||||
private final MatchRuleConfigProperties matchRuleConfigProperties;
|
||||
|
||||
@Override
|
||||
public int getRecommendationMinScore() {
|
||||
return matchRuleConfigProperties.getDefaultScoreThreshold();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getRuleVersion() {
|
||||
return matchRuleConfigProperties.getRuleVersion();
|
||||
}
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
package com.danyang.match.platform.shared.policy;
|
||||
|
||||
import com.danyang.match.platform.shared.config.ProfileProperties;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* {@link ProfilePolicy} 默认实现:从 {@code profile.photo.max-count} 配置读取。
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class DefaultProfilePolicy implements ProfilePolicy {
|
||||
|
||||
private final ProfileProperties profileProperties;
|
||||
|
||||
@Override
|
||||
public int getMaxPhotoCount() {
|
||||
return profileProperties.getPhoto().getMaxCount();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.danyang.match.platform.shared.policy;
|
||||
|
||||
/**
|
||||
* 实名 / 活体核验规则统一读取入口(CFG-001 + IDENTITY-001,V1.0.1 配置与规则纠偏)。
|
||||
*
|
||||
* <p><b>活体核验不是 V1 强制规则</b>:canonical 配置键 {@code identity.liveness.enabled}
|
||||
* (实现键 {@code features.livenessEnabled},见 config-register)默认 {@code false}。
|
||||
* 开关关闭时不得阻断实名主流程、不得要求用户完成活体(FF-LIVENESS-001/004)。
|
||||
*
|
||||
* <p>本接口是「假开关」纠偏后的<b>真实业务判定点</b>:实名链路经此决定是否需要活体,
|
||||
* 开关打开时业务行为确实改变(FF-LIVENESS-002),不存在「配置打开但业务无变化」。
|
||||
*/
|
||||
public interface IdentityPolicy {
|
||||
|
||||
/**
|
||||
* 是否强制活体核验。由 Feature Flag {@code livenessEnabled} 驱动(默认 false,fail-safe)。
|
||||
* 配置缺失时保持默认 false,绝不自动开启。
|
||||
*/
|
||||
boolean isLivenessRequired();
|
||||
|
||||
/** 当前活体核验 Provider 标识(none / third-party)。none 表示尚未接入合规第三方。 */
|
||||
String getLivenessProvider();
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.danyang.match.platform.shared.policy;
|
||||
|
||||
/**
|
||||
* 推荐 / 匹配规则统一读取入口。
|
||||
*
|
||||
* <p>业务代码(Controller 下发、推荐引擎、资格判定)不得各自写死阈值数值,
|
||||
* 一律经本接口取「当前生效值」,使配置源唯一({@code MatchRuleConfigProperties},
|
||||
* yml 键 {@code match.default-score-threshold} / {@code match.rule-version})。
|
||||
*
|
||||
* <p><b>阈值 60 仅为当前技术默认值,未获产品正式确认</b>(OQ-MATCH-001 / config-register
|
||||
* 中 canonical 名 {@code matching.recommendation.minScore})。前端不得将其包装为
|
||||
* 「匹配成功率 / 合适概率」等对外承诺。
|
||||
*/
|
||||
public interface MatchingPolicy {
|
||||
|
||||
/**
|
||||
* 推荐最低评分阈值。技术默认 60,未获产品正式确认;可经
|
||||
* {@code match.default-score-threshold} 配置覆盖。
|
||||
*/
|
||||
int getRecommendationMinScore();
|
||||
|
||||
/** 匹配规则版本号(写入推荐记录以支持历史追溯)。 */
|
||||
int getRuleVersion();
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.danyang.match.platform.shared.policy;
|
||||
|
||||
/**
|
||||
* 个人资料规则统一读取入口。
|
||||
*
|
||||
* <p>业务代码(Controller / Service / 前端下发配置)不得各自写死照片上限数值,
|
||||
* 一律经本接口取「当前生效值」,使配置源唯一({@code ProfileProperties})。
|
||||
*/
|
||||
public interface ProfilePolicy {
|
||||
|
||||
/**
|
||||
* 个人照片最大数量(头像 + 生活照合计)。
|
||||
* 技术默认 6,未获产品正式确认;可经 {@code profile.photo.max-count} 配置覆盖。
|
||||
*/
|
||||
int getMaxPhotoCount();
|
||||
}
|
||||
+15
@@ -62,6 +62,21 @@ public class IdentityVerificationController {
|
||||
return ApiResponse.ok(identityVerificationService.getIdentityStatus(userId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询活体核验状态(V1.0.1 配置纠偏 IDENTITY-001:真实业务判定点)。
|
||||
* GET /api/v1/verification/liveness-status
|
||||
*
|
||||
* <p>返回 {@code livenessRequired}(由 Feature Flag 驱动,默认 false)、
|
||||
* {@code livenessVerificationStatus}(服务器事实)、{@code enhancedVerified}。
|
||||
* 开关关闭时不影响实名主流程;前端不得据此误提示「必须进行人脸认证」。
|
||||
*/
|
||||
@GetMapping("/liveness-status")
|
||||
public ApiResponse<Map<String, Object>> getLivenessStatus(
|
||||
@AuthenticationPrincipal UserPrincipal principal) {
|
||||
String userId = principal.id();
|
||||
return ApiResponse.ok(identityVerificationService.getLivenessStatus(userId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 撤销实名(仅 failed 状态可撤销)
|
||||
* POST /api/v1/verification/cancel
|
||||
|
||||
+43
@@ -9,6 +9,7 @@ import com.danyang.match.platform.user.mapper.UserMapper;
|
||||
import com.danyang.match.platform.verification.entity.UserIdentity;
|
||||
import com.danyang.match.platform.verification.mapper.UserIdentityMapper;
|
||||
import com.danyang.match.platform.verification.provider.IdentityVerificationProvider;
|
||||
import com.danyang.match.platform.shared.policy.IdentityPolicy;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
@@ -33,6 +34,7 @@ public class IdentityVerificationService {
|
||||
private final IdentityVerificationProvider identityProvider;
|
||||
private final EncryptionUtil encryptionUtil;
|
||||
private final AuditLogMapper auditLogMapper;
|
||||
private final IdentityPolicy identityPolicy;
|
||||
|
||||
/**
|
||||
* 提交实名认证
|
||||
@@ -160,6 +162,47 @@ public class IdentityVerificationService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 活体核验是否强制(IDENTITY-001 业务判定委托)。开关由 Feature Flag 驱动,默认 false。
|
||||
*/
|
||||
public boolean requiresLiveness() {
|
||||
return identityPolicy.isLivenessRequired();
|
||||
}
|
||||
|
||||
/**
|
||||
* 活体核验状态(IDENTITY-001 真实业务判定点,V1.0.1 配置纠偏)。
|
||||
*
|
||||
* <p>开关({@code identity.liveness.enabled})打开时,<b>增强核验</b>需要活体通过;
|
||||
* 活体状态取自服务器事实 {@code user_identities.liveness_verification_status},
|
||||
* 前端伪造的「已通过」无效(FF-LIVENESS-005)。
|
||||
*
|
||||
* <ul>
|
||||
* <li>FF-LIVENESS-001:开关关闭 → {@code livenessRequired=false},不阻断实名主流程;</li>
|
||||
* <li>FF-LIVENESS-002:开关打开 → {@code livenessRequired=true},未活体通过则
|
||||
* {@code enhancedVerified=false}(业务状态确实改变,非假开关);</li>
|
||||
* <li>FF-LIVENESS-004:配置缺失 → 沿用默认 false,不自动开启。</li>
|
||||
* </ul>
|
||||
*/
|
||||
public Map<String, Object> getLivenessStatus(String userId) {
|
||||
boolean required = identityPolicy.isLivenessRequired();
|
||||
UserIdentity identity = userIdentityMapper.findByUserId(userId).orElse(null);
|
||||
String livenessStatus = identity != null ? identity.getLivenessVerificationStatus() : null;
|
||||
if (livenessStatus == null) {
|
||||
livenessStatus = "not_required";
|
||||
}
|
||||
boolean basicVerified = identity != null
|
||||
&& Constants.VERIFICATION_STATUS_VERIFIED.equals(identity.getVerificationStatus());
|
||||
boolean livenessPassed = "passed".equals(livenessStatus);
|
||||
Map<String, Object> status = new HashMap<>();
|
||||
status.put("userId", userId);
|
||||
status.put("livenessRequired", required);
|
||||
status.put("livenessProvider", identityPolicy.getLivenessProvider());
|
||||
status.put("livenessVerificationStatus", livenessStatus);
|
||||
// 增强核验 = 基础实名通过 且(未要求活体 或 活体已通过)
|
||||
status.put("enhancedVerified", basicVerified && (!required || livenessPassed));
|
||||
return status;
|
||||
}
|
||||
|
||||
/**
|
||||
* 撤销实名(仅限 failed 状态)
|
||||
*/
|
||||
|
||||
@@ -47,6 +47,11 @@ match:
|
||||
default-cooldown-days: 180
|
||||
local-eligibility-required: ${MATCH_LOCAL_ELIGIBILITY_REQUIRED:false}
|
||||
|
||||
# Profile(个人照片上限:技术默认 6,未获产品正式确认;经环境变量可覆盖)
|
||||
profile:
|
||||
photo:
|
||||
max-count: ${PROFILE_PHOTO_MAX_COUNT:6}
|
||||
|
||||
activity:
|
||||
default-need-review: true
|
||||
default-mutual-max-selection: 3
|
||||
|
||||
@@ -449,7 +449,6 @@ DROP TABLE IF EXISTS _conv_result;
|
||||
-- V1.1 Feature Flag 初始化
|
||||
-- ============================================================
|
||||
INSERT INTO system_configs (config_key, config_value, description) VALUES
|
||||
('livenessVerification.enabled', 'false', '人脸核验功能开关(V1.1预留,默认关闭)'),
|
||||
('localEligibility.evidencePolicy', '"need_based"', '本地资格佐证策略:need_based=按需触发 / mandatory=全部强制'),
|
||||
('counseling.enabled', 'true', '婚恋咨询功能开关'),
|
||||
('premarital.enabled', 'true', '婚前辅导功能开关'),
|
||||
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
-- V050: 推荐记录补「阈值快照」列(MATCH-001,V1.0.1 配置与规则纠偏)
|
||||
--
|
||||
-- 背景:推荐最低评分阈值 60 是<b>技术默认值</b>(match.default-score-threshold,未获产品
|
||||
-- 正式确认,见 OQ-MATCH-001 / config-register 的 matching.recommendation.minScore)。
|
||||
-- 未来阈值调整(60 → 65 …)时,需要能追溯「某条历史推荐当时依据的是哪一版规则、哪个阈值」,
|
||||
-- 故推荐记录除已有的 rule_version 外,再快照当次判定所用的阈值。
|
||||
--
|
||||
-- 设计:
|
||||
-- * threshold_snapshot 可空(INT)。历史行无快照(NULL),新增行写入当次阈值;
|
||||
-- * 与 rule_version 配合构成「规则版本 + 阈值快照」,供 analytics 按版本切分口径;
|
||||
-- * 不 DROP 任何列(Flyway 只向前),仅 ALTER ADD COLUMN。
|
||||
|
||||
ALTER TABLE recommendation_records
|
||||
ADD COLUMN IF NOT EXISTS threshold_snapshot INT;
|
||||
|
||||
COMMENT ON COLUMN recommendation_records.threshold_snapshot IS
|
||||
'推荐判定当次所用的最低评分阈值快照(对应 match.default-score-threshold);历史行为 NULL。配合 rule_version 追溯历史推荐依据的规则版本。';
|
||||
+3
-1
@@ -27,7 +27,9 @@ class MigrationValidationTest {
|
||||
// (此前只改内存单例,重启即丢、多实例不一致)
|
||||
// 数据保留(2026-09-19 产品确认保留期下限 36 个月):
|
||||
// V049 users.closed_at(保留期起算点,此前注销时间只存在于日志)+ account.retention.purge 权限
|
||||
private static final int LATEST_MIGRATION = 49;
|
||||
// V1.0.1 配置与规则纠偏(MATCH-001):V050 recommendation_records.threshold_snapshot
|
||||
// —— 推荐阈值 60 为技术默认(未产品确认),记录当次阈值快照 + rule_version 以支持历史追溯
|
||||
private static final int LATEST_MIGRATION = 50;
|
||||
|
||||
private static final String MIGRATION_DIR =
|
||||
"backend/src/main/resources/db/migration/";
|
||||
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
package com.danyang.match.platform.match.service;
|
||||
|
||||
import com.danyang.match.platform.match.config.MatchRuleConfigProperties;
|
||||
import com.danyang.match.platform.match.entity.RecommendationRecord;
|
||||
import com.danyang.match.platform.match.mapper.RecommendationRecordMapper;
|
||||
import com.danyang.match.platform.profile.mapper.UserProfileMapper;
|
||||
import com.danyang.match.platform.profile.service.UserProfileService;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.Mockito;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
|
||||
/**
|
||||
* 推荐阈值纠偏 + 规则版本化守卫(MATCH-001,V1.0.1 配置与规则纠偏)。
|
||||
*
|
||||
* <p>验证:① 阈值 60 仅为技术默认值(受控、非无限/非硬编码业务判断);② 阈值可配置,
|
||||
* 改配置即改行为(无需改代码);③ 推荐记录写入「当次阈值快照 + 规则版本」,
|
||||
* 支持未来 60→65 时追溯历史推荐依据(OQ-MATCH-001 / config-register 的
|
||||
* matching.recommendation.minScore)。
|
||||
*/
|
||||
class RecommendationThresholdSnapshotGuardTest {
|
||||
|
||||
private final MatchRuleConfigProperties config = new MatchRuleConfigProperties();
|
||||
private final RecommendationRecordMapper recordMapper = Mockito.mock(RecommendationRecordMapper.class);
|
||||
private final RecommendationService service = new RecommendationService(
|
||||
Mockito.mock(MatchEligibilityService.class),
|
||||
Mockito.mock(UserProfileMapper.class),
|
||||
recordMapper,
|
||||
config,
|
||||
Mockito.mock(UserProfileService.class));
|
||||
|
||||
private RecommendationRecord captureInserted() {
|
||||
ArgumentCaptor<RecommendationRecord> captor = ArgumentCaptor.forClass(RecommendationRecord.class);
|
||||
Mockito.verify(recordMapper).insert(captor.capture());
|
||||
return captor.getValue();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("阈值默认为受控默认值 60(非硬编码业务判断,亦非无限)")
|
||||
void defaultThresholdIsControlled() {
|
||||
assertEquals(60, new MatchRuleConfigProperties().getDefaultScoreThreshold());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("推荐记录写入当次阈值快照 + 规则版本(默认 60 / v1)")
|
||||
void recordCarriesThresholdSnapshotAndRuleVersion() {
|
||||
service.saveRecommendationRecord("u1", "u2", "medium", java.util.List.of("reason"));
|
||||
RecommendationRecord rec = captureInserted();
|
||||
assertEquals(60, rec.getThresholdSnapshot());
|
||||
assertEquals(1, rec.getRuleVersion());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("阈值改为 65(仅改配置)→ 快照随之为 65,无需改代码")
|
||||
void raisingThresholdViaConfigFlowsIntoSnapshot() {
|
||||
config.setDefaultScoreThreshold(65);
|
||||
config.setRuleVersion(2);
|
||||
service.saveRecommendationRecord("u1", "u2", "high", java.util.List.of("reason"));
|
||||
RecommendationRecord rec = captureInserted();
|
||||
assertEquals(65, rec.getThresholdSnapshot());
|
||||
assertEquals(2, rec.getRuleVersion());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("阈值切换:低于阈值被拒(引擎判定读配置,非写死 60)")
|
||||
void thresholdGateReadsConfig() {
|
||||
// 与引擎 :232 的判定一致:score < matchConfig.getDefaultScoreThreshold() 则不推荐
|
||||
int threshold = config.getDefaultScoreThreshold(); // 60
|
||||
assertFalse(45 >= threshold, "45 低于阈值 60 应被过滤");
|
||||
assertTrue(70 >= threshold, "70 高于阈值 60 应通过");
|
||||
config.setDefaultScoreThreshold(65);
|
||||
assertFalse(62 >= config.getDefaultScoreThreshold(), "阈值提到 65 后 62 应被过滤");
|
||||
assertTrue(70 >= config.getDefaultScoreThreshold(), "70 仍高于 65");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package com.danyang.match.platform.photo;
|
||||
|
||||
import com.amazonaws.services.s3.AmazonS3;
|
||||
import com.amazonaws.services.s3.model.ObjectMetadata;
|
||||
import com.danyang.match.platform.photo.entity.UserPhoto;
|
||||
import com.danyang.match.platform.photo.mapper.UserPhotoMapper;
|
||||
import com.danyang.match.platform.photo.service.PhotoService;
|
||||
import com.danyang.match.platform.shared.config.FeatureFlagConfig;
|
||||
import com.danyang.match.platform.shared.config.ProfileProperties;
|
||||
import com.danyang.match.platform.shared.enums.ApiResponseCode;
|
||||
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
|
||||
import com.danyang.match.platform.shared.policy.ProfilePolicy;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
/**
|
||||
* 照片数量上限守卫(PROFILE-001 / OQ-PROFILE-001,V1.0.1 配置与规则纠偏)。
|
||||
*
|
||||
* <p>历史缺陷:{@code AppController} 把「最多 6 张」写死、{@code PhotoService} 上传路径
|
||||
* 完全没有数量校验 —— 前端提示 6 张但后端不限,前后端规则漂移 + 并发可突破。本守卫用
|
||||
* mock 的 {@code lockActiveProfilePhotoIds}(FOR UPDATE 锁行数)+ 可配置
|
||||
* {@code ProfilePolicy} 验证:上限来自服务端配置、lifestyle 超限拒绝、avatar 为槽位替换、
|
||||
* 本地资格佐证不计入、拒绝发生在对象存储上传之前(不留孤儿对象)、默认值受控非无限。
|
||||
*
|
||||
* <p>说明:真实并发突破由数据库 {@code FOR UPDATE} 串行化保证(见 UserPhotoMapper 注释),
|
||||
* 单测断言的是「计数-拒绝」判定逻辑与「拒绝先于 S3 上传」的顺序,二者共同覆盖 PHOTO-001~007。
|
||||
*/
|
||||
class PhotoLimitGuardTest {
|
||||
|
||||
private static final byte[] JPEG_BYTES = {
|
||||
(byte) 0xFF, (byte) 0xD8, (byte) 0xFF, (byte) 0xE0, 0, 0, 0, 0, 0, 0, 0, 0
|
||||
};
|
||||
|
||||
/** 构造一个「active 个人照片数 = activeCount、配置上限 = maxCount」的 PhotoService。 */
|
||||
private PhotoService service(int activeCount, int maxCount,
|
||||
AmazonS3 s3, UserPhotoMapper mapper) {
|
||||
when(mapper.lockActiveProfilePhotoIds(anyString())).thenReturn(ids(activeCount));
|
||||
when(mapper.insert(any(UserPhoto.class))).thenReturn(1);
|
||||
ProfilePolicy policy = mock(ProfilePolicy.class);
|
||||
when(policy.getMaxPhotoCount()).thenReturn(maxCount);
|
||||
return new PhotoService(mapper, s3, mock(FeatureFlagConfig.class), policy);
|
||||
}
|
||||
|
||||
private static List<String> ids(int n) {
|
||||
List<String> list = new ArrayList<>();
|
||||
for (int i = 0; i < n; i++) {
|
||||
list.add("id-" + i);
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("PHOTO-001:上限6、已有5张 → 第6张 lifestyle 通过")
|
||||
void lifestyleBelowLimitAllowed() {
|
||||
AmazonS3 s3 = mock(AmazonS3.class);
|
||||
UserPhotoMapper mapper = mock(UserPhotoMapper.class);
|
||||
PhotoService svc = service(5, 6, s3, mapper);
|
||||
|
||||
assertDoesNotThrow(() -> svc.ensureWithinPhotoLimit("u1", "lifestyle"));
|
||||
verify(mapper).lockActiveProfilePhotoIds("u1"); // 确实走了 FOR UPDATE 锁计数
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("PHOTO-002:已有6张 → 下一张 lifestyle 由后端拒绝")
|
||||
void lifestyleAtLimitRejected() {
|
||||
AmazonS3 s3 = mock(AmazonS3.class);
|
||||
UserPhotoMapper mapper = mock(UserPhotoMapper.class);
|
||||
PhotoService svc = service(6, 6, s3, mapper);
|
||||
|
||||
BusinessException ex = assertThrows(BusinessException.class,
|
||||
() -> svc.ensureWithinPhotoLimit("u1", "lifestyle"));
|
||||
assertEquals(ApiResponseCode.PHOTO_LIMIT_EXCEEDED, ex.getCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("PHOTO-003:上限由6改8(仅改配置)→ 已有6张仍可传第7张")
|
||||
void raisingMaxCountViaConfigAllowsMore() {
|
||||
AmazonS3 s3 = mock(AmazonS3.class);
|
||||
UserPhotoMapper mapper = mock(UserPhotoMapper.class);
|
||||
PhotoService svc = service(6, 8, s3, mapper);
|
||||
|
||||
assertDoesNotThrow(() -> svc.ensureWithinPhotoLimit("u1", "lifestyle"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("PHOTO-004/005:avatar 为槽位替换(总量不变),未超限时放行")
|
||||
void avatarReplacementAllowedWhenWithinLimit() {
|
||||
AmazonS3 s3 = mock(AmazonS3.class);
|
||||
UserPhotoMapper mapper = mock(UserPhotoMapper.class);
|
||||
// 已有 1 avatar + 5 lifestyle = 6,替换头像总量不变
|
||||
PhotoService svc = service(6, 6, s3, mapper);
|
||||
|
||||
assertDoesNotThrow(() -> svc.ensureWithinPhotoLimit("u1", "avatar"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("已删除/失效照片不计入(mapper 只返回 active 行,此处以 0 active 验证)")
|
||||
void deletedPhotosNotCounted() {
|
||||
AmazonS3 s3 = mock(AmazonS3.class);
|
||||
UserPhotoMapper mapper = mock(UserPhotoMapper.class);
|
||||
when(mapper.lockActiveProfilePhotoIds(anyString())).thenReturn(ids(0));
|
||||
ProfilePolicy policy = mock(ProfilePolicy.class);
|
||||
when(policy.getMaxPhotoCount()).thenReturn(6);
|
||||
PhotoService svc = new PhotoService(mapper, s3, mock(FeatureFlagConfig.class), policy);
|
||||
|
||||
// 即便历史上传过 10 张(已软删),active=0 → 可再传
|
||||
assertDoesNotThrow(() -> svc.ensureWithinPhotoLimit("u1", "lifestyle"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("PHOTO-006:本地资格佐证不计入个人照片上限(独立上限,直接跳过)")
|
||||
void localEligibilityExcludedFromProfileLimit() {
|
||||
AmazonS3 s3 = mock(AmazonS3.class);
|
||||
UserPhotoMapper mapper = mock(UserPhotoMapper.class);
|
||||
// 即使个人照片已超上限,佐证类型也不应被个人照片上限拦截
|
||||
PhotoService svc = service(99, 6, s3, mapper);
|
||||
|
||||
assertDoesNotThrow(() -> svc.ensureWithinPhotoLimit("u1", "local_eligibility"));
|
||||
verify(mapper, never()).lockActiveProfilePhotoIds(anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("PHOTO-007:拒绝发生在对象存储上传之前(不留孤儿对象)")
|
||||
void uploadRejectedBeforeS3WhenOverLimit() {
|
||||
AmazonS3 s3 = mock(AmazonS3.class);
|
||||
UserPhotoMapper mapper = mock(UserPhotoMapper.class);
|
||||
PhotoService svc = service(6, 6, s3, mapper);
|
||||
|
||||
MockMultipartFile file = new MockMultipartFile("file", "a.jpg", "image/jpeg", JPEG_BYTES);
|
||||
assertThrows(BusinessException.class,
|
||||
() -> svc.uploadAndSave("u1", file, "lifestyle", 0));
|
||||
verify(s3, never()).putObject(any(), anyString(), any(InputStream.class), any(ObjectMetadata.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("PHOTO-007b:前端伪造 maxCount 无效——后端只认服务端配置(此默认 6 受控非无限)")
|
||||
void serverConfigIsSourceOfTruthAndDefaultIsControlled() {
|
||||
// ProfileProperties 缺省即为受控默认 6(不因配置读取失败而放开为无限)
|
||||
assertEquals(6, new ProfileProperties().getPhoto().getMaxCount());
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class PhotoServiceTest {
|
||||
|
||||
private final PhotoService photoService = new PhotoService(null, null, null);
|
||||
private final PhotoService photoService = new PhotoService(null, null, null, null);
|
||||
|
||||
@Test
|
||||
void testValidateMagicNumberJpeg() {
|
||||
|
||||
+3
-1
@@ -6,6 +6,8 @@ import com.danyang.match.platform.photo.entity.UserPhoto;
|
||||
import com.danyang.match.platform.photo.mapper.UserPhotoMapper;
|
||||
import com.danyang.match.platform.photo.service.PhotoService;
|
||||
import com.danyang.match.platform.shared.config.FeatureFlagConfig;
|
||||
import com.danyang.match.platform.shared.config.ProfileProperties;
|
||||
import com.danyang.match.platform.shared.policy.DefaultProfilePolicy;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
@@ -36,7 +38,7 @@ class PhotoUploadValidationTest {
|
||||
};
|
||||
|
||||
private PhotoService newService(AmazonS3 s3, UserPhotoMapper mapper, FeatureFlagConfig flags) {
|
||||
return new PhotoService(mapper, s3, flags);
|
||||
return new PhotoService(mapper, s3, flags, new DefaultProfilePolicy(new ProfileProperties()));
|
||||
}
|
||||
|
||||
private FeatureFlagConfig localEvidenceFlags(int maxMB, String allowedTypes) {
|
||||
|
||||
@@ -11,15 +11,6 @@ import org.springframework.test.context.ActiveProfiles;
|
||||
@ActiveProfiles("test")
|
||||
class ConfigTest {
|
||||
|
||||
@Test
|
||||
void testMatchConfigDefaults() {
|
||||
MatchConfig config = new MatchConfig();
|
||||
assertEquals(6, config.getDefaultDailyLimit());
|
||||
assertEquals(60, config.getDefaultScoreThreshold());
|
||||
assertEquals(180, config.getDefaultCooldownDays());
|
||||
assertEquals(1, config.getRuleVersion());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testActivityConfigDefaults() {
|
||||
ActivityConfig config = new ActivityConfig();
|
||||
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
package com.danyang.match.platform.verification;
|
||||
|
||||
import com.danyang.match.platform.shared.config.FeatureFlagConfig;
|
||||
import com.danyang.match.platform.shared.policy.DefaultIdentityPolicy;
|
||||
import com.danyang.match.platform.shared.policy.IdentityPolicy;
|
||||
import com.danyang.match.platform.verification.entity.UserIdentity;
|
||||
import com.danyang.match.platform.verification.mapper.UserIdentityMapper;
|
||||
import com.danyang.match.platform.verification.service.IdentityVerificationService;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.Mockito;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* 活体核验开关守卫(CFG-001 + IDENTITY-001,V1.0.1 配置与规则纠偏)。
|
||||
*
|
||||
* <p>验证「假开关」纠偏后开关真实驱动业务:默认关闭(fail-safe,FF-001/004)、
|
||||
* 打开后增强核验状态确实改变(FF-002)、活体状态以服务器事实为准(FF-005)。
|
||||
* 纯 Mockito,不起 Spring 上下文(活体开关无 DB 外部依赖)。
|
||||
*/
|
||||
class IdentityLivenessGuardTest {
|
||||
|
||||
private IdentityVerificationService service(UserIdentityMapper mapper, IdentityPolicy policy) {
|
||||
// getLivenessStatus 仅依赖 userIdentityMapper + identityPolicy,其余注入 null
|
||||
return new IdentityVerificationService(mapper, null, null, null, null, policy);
|
||||
}
|
||||
|
||||
private UserIdentity identity(String livenessStatus) {
|
||||
UserIdentity i = new UserIdentity();
|
||||
i.setUserId("u1");
|
||||
i.setVerificationStatus("verified");
|
||||
i.setLivenessVerificationStatus(livenessStatus);
|
||||
return i;
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("FF-LIVENESS-001:开关默认关闭 → 不要求活体,基础实名通过即增强核验通过")
|
||||
void livenessOffDoesNotBlock() {
|
||||
FeatureFlagConfig flags = new FeatureFlagConfig(); // 默认 livenessEnabled=false
|
||||
IdentityPolicy policy = new DefaultIdentityPolicy(flags);
|
||||
assertFalse(policy.isLivenessRequired());
|
||||
|
||||
UserIdentityMapper mapper = Mockito.mock(UserIdentityMapper.class);
|
||||
Mockito.when(mapper.findByUserId("u1")).thenReturn(Optional.of(identity(null)));
|
||||
IdentityVerificationService svc = service(mapper, policy);
|
||||
|
||||
Map<String, Object> st = svc.getLivenessStatus("u1");
|
||||
assertEquals(Boolean.FALSE, st.get("livenessRequired"));
|
||||
assertEquals(Boolean.TRUE, st.get("enhancedVerified")); // 未要求活体,基础通过即通过
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("FF-LIVENESS-002:开关打开 + 活体未通过 → 增强核验不通过(业务确实改变)")
|
||||
void livenessOnBlocksEnhancedWhenNotPassed() {
|
||||
FeatureFlagConfig flags = new FeatureFlagConfig();
|
||||
flags.setLivenessEnabled(true);
|
||||
IdentityPolicy policy = new DefaultIdentityPolicy(flags);
|
||||
assertTrue(policy.isLivenessRequired());
|
||||
|
||||
UserIdentityMapper mapper = Mockito.mock(UserIdentityMapper.class);
|
||||
Mockito.when(mapper.findByUserId("u1")).thenReturn(Optional.of(identity("not_passed")));
|
||||
IdentityVerificationService svc = service(mapper, policy);
|
||||
|
||||
Map<String, Object> st = svc.getLivenessStatus("u1");
|
||||
assertEquals(Boolean.TRUE, st.get("livenessRequired"));
|
||||
assertEquals(Boolean.FALSE, st.get("enhancedVerified")); // 要求活体但未过
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("FF-LIVENESS-002b:开关打开 + 活体已通过 → 增强核验通过")
|
||||
void livenessOnPassesWhenLivenessPassed() {
|
||||
FeatureFlagConfig flags = new FeatureFlagConfig();
|
||||
flags.setLivenessEnabled(true);
|
||||
IdentityPolicy policy = new DefaultIdentityPolicy(flags);
|
||||
|
||||
UserIdentityMapper mapper = Mockito.mock(UserIdentityMapper.class);
|
||||
Mockito.when(mapper.findByUserId("u1")).thenReturn(Optional.of(identity("passed")));
|
||||
IdentityVerificationService svc = service(mapper, policy);
|
||||
|
||||
Map<String, Object> st = svc.getLivenessStatus("u1");
|
||||
assertEquals(Boolean.TRUE, st.get("enhancedVerified"));
|
||||
assertEquals("passed", st.get("livenessVerificationStatus"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("FF-LIVENESS-004:配置缺失 → fail-safe 默认关闭,不自动开启")
|
||||
void missingConfigFailsSafeToOff() {
|
||||
// 全新 FeatureFlagConfig(无任何 yml/覆盖)即为默认 false
|
||||
FeatureFlagConfig flags = new FeatureFlagConfig();
|
||||
assertFalse(new DefaultIdentityPolicy(flags).isLivenessRequired());
|
||||
assertEquals("none", flags.getLivenessProvider());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("FF-LIVENESS-005:活体状态以服务器事实为准(前端伪造无效)")
|
||||
void livenessStatusFromServerFact() {
|
||||
FeatureFlagConfig flags = new FeatureFlagConfig();
|
||||
flags.setLivenessEnabled(true);
|
||||
IdentityPolicy policy = new DefaultIdentityPolicy(flags);
|
||||
|
||||
// 服务器记录 livenessVerificationStatus=passed → 返回 passed(不受任何前端入参影响)
|
||||
UserIdentityMapper mapper = Mockito.mock(UserIdentityMapper.class);
|
||||
Mockito.when(mapper.findByUserId("u1")).thenReturn(Optional.of(identity("passed")));
|
||||
Map<String, Object> st = service(mapper, policy).getLivenessStatus("u1");
|
||||
assertEquals("passed", st.get("livenessVerificationStatus"));
|
||||
assertEquals(Boolean.TRUE, st.get("enhancedVerified"));
|
||||
|
||||
// 服务器记录为 failed → 即使「前端声称已通过」,也以服务器 failed 为准
|
||||
UserIdentityMapper mapper2 = Mockito.mock(UserIdentityMapper.class);
|
||||
Mockito.when(mapper2.findByUserId("u1")).thenReturn(Optional.of(identity("failed")));
|
||||
Map<String, Object> st2 = service(mapper2, policy).getLivenessStatus("u1");
|
||||
assertEquals("failed", st2.get("livenessVerificationStatus"));
|
||||
assertEquals(Boolean.FALSE, st2.get("enhancedVerified"));
|
||||
}
|
||||
}
|
||||
@@ -127,7 +127,7 @@ ALTER TABLE notifications ADD CONSTRAINT chk_notification_type CHECK (
|
||||
CREATE TABLE recommendation_records (
|
||||
id VARCHAR(36) PRIMARY KEY, user_id VARCHAR(36), target_user_id VARCHAR(36), source VARCHAR(32), score NUMERIC(5,2),
|
||||
level VARCHAR(16), reasons CLOB, shown_at TIMESTAMP WITH TIME ZONE, action VARCHAR(32), action_at TIMESTAMP WITH TIME ZONE,
|
||||
rule_version INT, created_at TIMESTAMP WITH TIME ZONE, updated_at TIMESTAMP WITH TIME ZONE, deleted_at TIMESTAMP WITH TIME ZONE
|
||||
rule_version INT, threshold_snapshot INT, created_at TIMESTAMP WITH TIME ZONE, updated_at TIMESTAMP WITH TIME ZONE, deleted_at TIMESTAMP WITH TIME ZONE
|
||||
);
|
||||
CREATE TABLE activity_registrations (
|
||||
id VARCHAR(36) PRIMARY KEY, activity_id VARCHAR(36), user_id VARCHAR(36), status VARCHAR(32),
|
||||
|
||||
Reference in New Issue
Block a user