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 状态)
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user