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:
WorkBuddy
2026-09-21 02:05:15 +08:00
parent b60c1ffb71
commit 438b38d121
37 changed files with 1033 additions and 101 deletions
@@ -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);
// 审核相关
@@ -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;
}
@@ -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);
}
@@ -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;
@@ -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}(数据库覆盖 &gt; yml/环境变量 &gt; 字段默认)。
*/
@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();
}
}
@@ -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();
}
}
@@ -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();
}
@@ -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
@@ -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 状态)
*/