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; 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 org.springframework.web.bind.annotation.*;
import java.util.HashMap; import java.util.HashMap;
@@ -11,8 +14,12 @@ import java.util.Map;
*/ */
@RestController @RestController
@RequestMapping("/app") @RequestMapping("/app")
@RequiredArgsConstructor
public class AppController { public class AppController {
private final ProfilePolicy profilePolicy;
private final MatchingPolicy matchingPolicy;
/** /**
* 获取应用配置 * 获取应用配置
* GET /api/v1/app/config * GET /api/v1/app/config
@@ -36,13 +43,17 @@ public class AppController {
config.put("features", features); 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<>(); Map<String, Object> businessLimits = new HashMap<>();
businessLimits.put("dailyRecommendationLimit", 6); businessLimits.put("dailyRecommendationLimit", 6);
businessLimits.put("maxProfilePhotos", 6); businessLimits.put("maxProfilePhotos", profilePolicy.getMaxPhotoCount());
businessLimits.put("maxInterestTags", 8); businessLimits.put("maxInterestTags", 8);
businessLimits.put("selfIntroMaxLength", 500); businessLimits.put("selfIntroMaxLength", 500);
businessLimits.put("recommendationCooldownDays", 180); businessLimits.put("recommendationCooldownDays", 180);
businessLimits.put("matchScoreThreshold", 60); businessLimits.put("matchScoreThreshold", matchingPolicy.getRecommendationMinScore());
config.put("businessLimits", businessLimits); config.put("businessLimits", businessLimits);
// 审核相关 // 审核相关
@@ -20,4 +20,9 @@ public class RecommendationRecord extends BaseEntity {
private String action; // liked, passed, blocked, matched private String action; // liked, passed, blocked, matched
private java.time.Instant actionAt; private java.time.Instant actionAt;
private Integer ruleVersion; 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.setReasons(String.join(";", reasons));
record.setShownAt(Instant.now()); record.setShownAt(Instant.now());
record.setRuleVersion(matchConfig.getRuleVersion()); record.setRuleVersion(matchConfig.getRuleVersion());
// MATCH-001:快照当次判定所用阈值(配合 rule_version 追溯历史推荐依据的规则版本)
record.setThresholdSnapshot(matchConfig.getDefaultScoreThreshold());
recommendationRecordMapper.insert(record); recommendationRecordMapper.insert(record);
} }
@@ -64,6 +64,8 @@ public class RecommendationService {
record.setReasons(new java.util.ArrayList<>(reasons).toString()); record.setReasons(new java.util.ArrayList<>(reasons).toString());
record.setShownAt(Instant.now()); record.setShownAt(Instant.now());
record.setRuleVersion(matchConfig.getRuleVersion()); record.setRuleVersion(matchConfig.getRuleVersion());
// MATCH-001:快照当次判定所用阈值(配合 rule_version 追溯历史推荐依据的规则版本)
record.setThresholdSnapshot(matchConfig.getDefaultScoreThreshold());
recommendationRecordMapper.insert(record); recommendationRecordMapper.insert(record);
log.debug("推荐记录: userId={}, targetUserId={}, level={}", userId, targetUserId, level); 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.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.danyang.match.platform.photo.entity.UserPhoto; import com.danyang.match.platform.photo.entity.UserPhoto;
import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select; import org.apache.ibatis.annotations.Select;
import java.util.List; 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") @Select("SELECT * FROM user_photos WHERE user_id = #{userId} AND photo_type = 'avatar' AND deleted_at IS NULL LIMIT 1")
UserPhoto findAvatar(String userId); 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.config.FeatureFlagConfig;
import com.danyang.match.platform.shared.enums.ApiResponseCode; import com.danyang.match.platform.shared.enums.ApiResponseCode;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler; import com.danyang.match.platform.shared.exception.GlobalExceptionHandler;
import com.danyang.match.platform.shared.policy.ProfilePolicy;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
@@ -38,6 +39,7 @@ public class PhotoService {
private final UserPhotoMapper photoMapper; private final UserPhotoMapper photoMapper;
private final AmazonS3 amazonS3; private final AmazonS3 amazonS3;
private final FeatureFlagConfig featureFlags; private final FeatureFlagConfig featureFlags;
private final ProfilePolicy profilePolicy;
@Value("${object-storage.bucket-private}") @Value("${object-storage.bucket-private}")
private String privateBucket; private String privateBucket;
@@ -98,11 +100,44 @@ public class PhotoService {
return Map.of("url", amazonS3.generatePresignedUrl(request).toString(), "expiresIn", 600); 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 @Transactional
public UserPhoto savePhoto(String userId, String fileKey, String photoType, int sortOrder) { public UserPhoto savePhoto(String userId, String fileKey, String photoType, int sortOrder) {
// 数量上限校验:所有 insert 路径统一在此强制(并发安全,见 ensureWithinPhotoLimit)
ensureWithinPhotoLimit(userId, photoType);
// 如果是头像,先清除旧头像 // 如果是头像,先清除旧头像
if ("avatar".equals(photoType)) { if ("avatar".equals(photoType)) {
photoMapper.update(null, new com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper<UserPhoto>() 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 并上传(使用全新输入流,与校验流隔离,互不干扰) // 3. 生成 key 并上传(使用全新输入流,与校验流隔离,互不干扰)
String fileKey = generateFileKey(userId, photoType, contentType); String fileKey = generateFileKey(userId, photoType, contentType);
try { 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), CROSS_ORGANIZATION_DENIED("CROSS_ORGANIZATION_DENIED", "无权访问其他机构数据", HttpStatus.FORBIDDEN),
OPERATION_NOT_ALLOWED("OPERATION_NOT_ALLOWED", "当前操作不允许", HttpStatus.FORBIDDEN), OPERATION_NOT_ALLOWED("OPERATION_NOT_ALLOWED", "当前操作不允许", HttpStatus.FORBIDDEN),
MATCHMAKER_CANNOT_LIKE("MATCHMAKER_CANNOT_LIKE", "红娘不能替用户操作喜欢", 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 code;
private final String description; 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)); 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 状态可撤销) * 撤销实名(仅 failed 状态可撤销)
* POST /api/v1/verification/cancel * 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.entity.UserIdentity;
import com.danyang.match.platform.verification.mapper.UserIdentityMapper; import com.danyang.match.platform.verification.mapper.UserIdentityMapper;
import com.danyang.match.platform.verification.provider.IdentityVerificationProvider; import com.danyang.match.platform.verification.provider.IdentityVerificationProvider;
import com.danyang.match.platform.shared.policy.IdentityPolicy;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
@@ -33,6 +34,7 @@ public class IdentityVerificationService {
private final IdentityVerificationProvider identityProvider; private final IdentityVerificationProvider identityProvider;
private final EncryptionUtil encryptionUtil; private final EncryptionUtil encryptionUtil;
private final AuditLogMapper auditLogMapper; 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 状态) * 撤销实名(仅限 failed 状态)
*/ */
@@ -47,6 +47,11 @@ match:
default-cooldown-days: 180 default-cooldown-days: 180
local-eligibility-required: ${MATCH_LOCAL_ELIGIBILITY_REQUIRED:false} local-eligibility-required: ${MATCH_LOCAL_ELIGIBILITY_REQUIRED:false}
# Profile(个人照片上限:技术默认 6,未获产品正式确认;经环境变量可覆盖)
profile:
photo:
max-count: ${PROFILE_PHOTO_MAX_COUNT:6}
activity: activity:
default-need-review: true default-need-review: true
default-mutual-max-selection: 3 default-mutual-max-selection: 3
@@ -449,7 +449,6 @@ DROP TABLE IF EXISTS _conv_result;
-- V1.1 Feature Flag 初始化 -- V1.1 Feature Flag 初始化
-- ============================================================ -- ============================================================
INSERT INTO system_configs (config_key, config_value, description) VALUES INSERT INTO system_configs (config_key, config_value, description) VALUES
('livenessVerification.enabled', 'false', '人脸核验功能开关(V1.1预留,默认关闭)'),
('localEligibility.evidencePolicy', '"need_based"', '本地资格佐证策略:need_based=按需触发 / mandatory=全部强制'), ('localEligibility.evidencePolicy', '"need_based"', '本地资格佐证策略:need_based=按需触发 / mandatory=全部强制'),
('counseling.enabled', 'true', '婚恋咨询功能开关'), ('counseling.enabled', 'true', '婚恋咨询功能开关'),
('premarital.enabled', 'true', '婚前辅导功能开关'), ('premarital.enabled', 'true', '婚前辅导功能开关'),
@@ -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 追溯历史推荐依据的规则版本。';
@@ -27,7 +27,9 @@ class MigrationValidationTest {
// (此前只改内存单例,重启即丢、多实例不一致) // (此前只改内存单例,重启即丢、多实例不一致)
// 数据保留(2026-09-19 产品确认保留期下限 36 个月): // 数据保留(2026-09-19 产品确认保留期下限 36 个月):
// V049 users.closed_at(保留期起算点,此前注销时间只存在于日志)+ account.retention.purge 权限 // 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 = private static final String MIGRATION_DIR =
"backend/src/main/resources/db/migration/"; "backend/src/main/resources/db/migration/";
@@ -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 { class PhotoServiceTest {
private final PhotoService photoService = new PhotoService(null, null, null); private final PhotoService photoService = new PhotoService(null, null, null, null);
@Test @Test
void testValidateMagicNumberJpeg() { void testValidateMagicNumberJpeg() {
@@ -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.mapper.UserPhotoMapper;
import com.danyang.match.platform.photo.service.PhotoService; import com.danyang.match.platform.photo.service.PhotoService;
import com.danyang.match.platform.shared.config.FeatureFlagConfig; 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.DisplayName;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockMultipartFile; import org.springframework.mock.web.MockMultipartFile;
@@ -36,7 +38,7 @@ class PhotoUploadValidationTest {
}; };
private PhotoService newService(AmazonS3 s3, UserPhotoMapper mapper, FeatureFlagConfig flags) { 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) { private FeatureFlagConfig localEvidenceFlags(int maxMB, String allowedTypes) {
@@ -11,15 +11,6 @@ import org.springframework.test.context.ActiveProfiles;
@ActiveProfiles("test") @ActiveProfiles("test")
class ConfigTest { 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 @Test
void testActivityConfigDefaults() { void testActivityConfigDefaults() {
ActivityConfig config = new ActivityConfig(); ActivityConfig config = new ActivityConfig();
@@ -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"));
}
}
+1 -1
View File
@@ -127,7 +127,7 @@ ALTER TABLE notifications ADD CONSTRAINT chk_notification_type CHECK (
CREATE TABLE recommendation_records ( 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), 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, 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 ( CREATE TABLE activity_registrations (
id VARCHAR(36) PRIMARY KEY, activity_id VARCHAR(36), user_id VARCHAR(36), status VARCHAR(32), id VARCHAR(36) PRIMARY KEY, activity_id VARCHAR(36), user_id VARCHAR(36), status VARCHAR(32),
+15 -10
View File
@@ -1,6 +1,11 @@
# 待确认决策项 — PENDING_DECISIONS.md # 待确认决策项 — PENDING_DECISIONS.md
> 以下参数尚未由产品负责人确认。已确认项请参见已确认列表。 > 以下参数尚未由产品负责人确认。已确认项请参见已确认列表。
>
> **治理结构(V1.0.1 配置与规则纠偏,2026-09-20)**:本文件不再同时承担「已确认决策 / 技术默认值 / 待确认事项」。
> 已拆分到 `docs/governance/`:**配置登记表** `config-register.md`(canonical 键 + 分类 + 是否产品确认)、
> **开放问题** `open-questions.md`(OQ-*,待产品裁决)、**决策登记表** `decision-register.md`(仅已确认项)。
> 本文件保留历史待确认区与「已确认列表」,并与上述三表互链;**矛盾以 governance 三表为准**。
--- ---
@@ -10,8 +15,8 @@
| 决策项 | 选项 | 状态 | | 决策项 | 选项 | 状态 |
|--------|------|------| |--------|------|------|
| 人脸核验是否V1.1首发强制 | 否(Feature Flag 控制) | ⏳ 待确认 | | 人脸核验是否V1.1首发强制 | 否(Feature Flag 控制,`identity.liveness.enabled` 默认 `false`) | ⏳ 待确认(CONFIG/TBD,**非 V1 强制**;本轮已接 `IdentityPolicy` 真实业务判定点,见 config-register / OQ-IDENTITY-001) |
| 人脸核验 Provider | 待定 | ⏳ 待确认 | | 人脸核验 Provider | 待定(`identity.liveness.provider` 默认 `none`) | ⏳ 待确认(选型未定,见 OQ-IDENTITY-001) |
### 本地资格 ### 本地资格
@@ -38,11 +43,11 @@
| 决策项 | 技术默认值 | 状态 | | 决策项 | 技术默认值 | 状态 |
|--------|-----------|------| |--------|-----------|------|
| Banner最大活跃数量 | 待定 | ⏳ 待确认 | | Banner最大活跃数量 | 待定(**真缺口**:代码无此限制,仅 seed 死值 5) | ⏳ 待确认(OQ-BANNER-001,需产品定值后实现 active-count 校验) |
| 推荐每日数量 | 6人 | ✅ 已完成 | | 推荐每日数量 | 6人 | ✅ 已完成(LOCKED,`match.default-daily-limit`) |
| 推荐阈值/最终权重 | 待定 | ⏳ 待确认 | | 推荐阈值/最终权重 | **技术默认 60,未产品确认**(`matching.recommendation.minScore`,可配置 + V050 阈值快照) | ⏳ 待确认(OQ-MATCH-001;消解本文 :「阈值60分已确认」矛盾 —— 60 只是默认值) |
| Pass冷却时间 | 180天 | ✅ 已完成 | | Pass冷却时间 | 180天 | ✅ 已完成(LOCKED,`match.default-cooldown-days`) |
| 照片上限 | 6张 | ✅ 已完成 | | 照片上限 | **技术默认 6,未产品确认**(`profile.photo.maxCount`,已配置化 + 服务器端强校验) | ⏳ 待确认(OQ-PROFILE-001;原标「✅ 已确认 6」有误,6 非产品正式确认值) |
### 数据统计 ### 数据统计
@@ -72,8 +77,8 @@
| 内容 | 婚恋课堂 + 婚恋反诈进入V1.1首发 | | 内容 | 婚恋课堂 + 婚恋反诈进入V1.1首发 |
| 活动 | 多类型公益活动 + 活动双向沟通确认进入V1.1首发 | | 活动 | 多类型公益活动 + 活动双向沟通确认进入V1.1首发 |
| 核心规则 | Required不可放宽 / Mutual后才Chat / Block优先 | | 核心规则 | Required不可放宽 / Mutual后才Chat / Block优先 |
| 推荐 | 每日6人 / 阈值60分 / Pass冷却180天 | | 推荐 | 每日6人(LOCKED)/ Pass冷却180天(LOCKED);**阈值60分仅为技术默认值,未产品确认**(OQ-MATCH-001) |
| 资料 | 照片上限6张 / 兴趣标签上限8个 / 自我介绍500字符 | | 资料 | 兴趣标签上限8个 / 自我介绍500字符(LOCKED);**照片上限6张仅为技术默认值,未产品确认**(OQ-PROFILE-001) |
| 隐私 | Privacy默认全TRUE | | 隐私 | Privacy默认全TRUE |
| 活动规则 | 需审核 / 男女名额独立配置 / 互选最大3人 / 截止后48小时 | | 活动规则 | 需审核 / 男女名额独立配置 / 互选最大3人 / 截止后48小时 |
| 关系状态 | searching/getting_to_know/paused/matched_successfully/service_exited | | 关系状态 | searching/getting_to_know/paused/matched_successfully/service_exited |
@@ -124,5 +129,5 @@
--- ---
*最后更新: 2026-09-19 — 新增「数据保留」已确认项(2026-09-19 产品确认:注销后数据保留不得低于 36 个月);历史部署声明 10 项已按各 Phase 复验证据回写处置结果;第三方开放遗留 7 项待决策* *最后更新: 2026-09-20 — V1.0.1 配置与规则纠偏:消解「推荐阈值 60 已确认 / 待定」矛盾(60 为技术默认,OQ-MATCH-001);照片上限 6 由「已确认」降级为「技术默认 / 未确认」(OQ-PROFILE-001);活体核验补 CONFIG/TBD 并接 IdentityPolicy 真实判定点(OQ-IDENTITY-001);拆分出 docs/governance/(config-register / open-questions / decision-register)。*
+3 -2
View File
@@ -1,10 +1,10 @@
# 项目状态跟踪 — STATUS.md # 项目状态跟踪 — STATUS.md
> **2026-09-20 当前有效结论:`V1.1 RECOVERY PHASE 01~22 CLOSED / PHASE 23 CLOSEOUT FULLY DONE (BATCH 1-3 CLEARED, DATA RETENTION FLOW LANDED) / HORIZONTAL HARDENING DONE / UI REDESIGN ACCEPTED BY USER / REMAINING: CI GREEN RUN (needs GitHub Actions) + BATCH 4 (USER) / PHASE 24 (UAT) AWAITING USER GO`。** > **2026-09-20 当前有效结论:`V1.1 RECOVERY PHASE 01~22 CLOSED / PHASE 23 CLOSEOUT FULLY DONE (BATCH 1-3 CLEARED, DATA RETENTION FLOW LANDED) / HORIZONTAL HARDENING DONE / UI REDESIGN ACCEPTED BY USER / REMAINING: CI GREEN RUN (needs GitHub Actions) + BATCH 4 (USER) / PHASE 24 (UAT) AWAITING USER GO`。** **V1.0.1 配置与规则纠偏已完成**(照片上限配置化 + 服务器端强校验 / 活体开关闭环 / 推荐阈值纠偏 + 规则版本化快照 / governance 三表 + 漂移审计):全量回归 **733 / 0 / 0 / 3**(基线 717 + 17 新增 − 1 删死测试),UI 契约门禁 **18/18**。
> >
> 阶段 0(在途收口)、阶段 1(小程序 C 组 + discover 筛选重设计)、阶段 2(管理端 Vue 新框架适配)、阶段 3(CapabilityService 独立化)、阶段 4(双端总验收)全部完成。三端构建通过、**13/13 契约全绿**(阶段 4 验收时点值;当前 UI 契约门禁为 **18/18**)、核心 11 页面**全部 TS-only(11/11)**。**UI 重设计首批 11 页已于 2026-09-16 通过用户验收(结论:基本达标)**;`/me/*` 前后端契约同轮收敛,17 个 404 调用已恢复。**Phase 08(Like / Match / Block)、Phase 09(Text Chat)、Phase 10(Image & Chat Safety)、Phase 11(Notification)、Phase 12(Relationship Progress)、Phase 13(公益红娘转介)、Phase 13A(婚恋咨询与婚前辅导)、Phase 14(多类型公益活动)、Phase 15(活动报名、审核与签到)、Phase 16(活动互选与双方开启沟通确认)、Phase 17(举报与风控)、Phase 18(申诉)、Phase 19(统一运营后台 + 用户端公益服务页面)、Phase 20(Analytics 数据统计与管理驾驶舱)复验已关闭**,十四轮共修复 130+ 处真实缺陷(含 3 处隐藏编译缺陷),并各自补齐测试保真度与守卫。**Phase 21、22 复验已关闭(2026-09-18)**;**Phase 23 为「部分关闭」(2026-09-18)** —— 已清除 44 个 `pass` 空壳与 14 个只断言常量的假不变量测试,新增 4 个经负向注入验证的真实行为守卫与空壳阻断闸门,后端回归 655 全绿;但 **E2E 进入 CI 因需「活栈供给方式」基础设施决策而列为遗留,故不宣布「失败为零可进入 UAT」**。**Phase 23B(E2E 测试基建重建,33→49 passed)、HTTP 语义检修(失败响应携带真实状态码)、幂等键检修(`Idempotency-Key` 30 端点落地,V045/V046)均已于 2026-09-18 完成**,后端全量回归 **669 / 0 failures / 0 errors / 3 skipped**。**管理端入口已收敛到 `/admin/` 子路径(vite base + router base + 三处 nginx 接线),staging 端口 13521;UI 契约门禁 18/18 全绿(第 15~18 项为 2026-09-19 新增)。Phase 24(UAT)启动需用户确认。** **2026-09-19 收尾推进(批 2/3,已全部完成)**:Phase 23 Task 2.1(互选 8 + 预约 8 场景真实实现,本机 E2E **65 passed**,负向注入 2 轮精准变红)与 Task 2.2(INV-011 显式行为守卫 9 用例)完成;批 3 处置 Phase 21/22 遗留——隐私设置接真实落库(**V047** 补列 + upsert 修复,守卫 8 用例含解析小程序源码的契约守卫)、5 个死开关 + 2 个私有桶死配置移除(产品裁定)、`PUT /feature-flags` 真持久化(**V048**,守卫 11 用例)、补 5 个「只有声明无 SQL」的 Mapper 方法并新增绑定守卫;**Task 3.3 四项 + 红娘服务拦截接线完成**(P16-T13 `expires_at` 写入接线、P13A-T31 随访审计、P13A-T33 随访正文风险检测、P04-T10 确认 Onboarding 本地资格早已实现、`checkCanWithdraw` 红娘 blocker 首次生效 + 注销竞态兜底),各项均带真实落库守卫并经负向注入验证。**全量回归 712 / 0 failures / 0 errors / 3 skipped(BUILD SUCCESS,2026-09-19 实测)→ 717(2026-09-20 实测,数据保留流程 +5)**;UI 契约门禁 **18/18**;**E2E 本机模拟 CI 序列实测 65/65 全绿(2026-09-20,宿主机 Python 3.11 + 活栈,GitHub Actions 流水线实测仍待一次推送)**。批 3 仅余:E2E 入 CI 的 GitHub Actions 实测绿(数据保留流程已于 2026-09-19/20 落地,见 V049 行)。 > 阶段 0(在途收口)、阶段 1(小程序 C 组 + discover 筛选重设计)、阶段 2(管理端 Vue 新框架适配)、阶段 3(CapabilityService 独立化)、阶段 4(双端总验收)全部完成。三端构建通过、**13/13 契约全绿**(阶段 4 验收时点值;当前 UI 契约门禁为 **18/18**)、核心 11 页面**全部 TS-only(11/11)**。**UI 重设计首批 11 页已于 2026-09-16 通过用户验收(结论:基本达标)**;`/me/*` 前后端契约同轮收敛,17 个 404 调用已恢复。**Phase 08(Like / Match / Block)、Phase 09(Text Chat)、Phase 10(Image & Chat Safety)、Phase 11(Notification)、Phase 12(Relationship Progress)、Phase 13(公益红娘转介)、Phase 13A(婚恋咨询与婚前辅导)、Phase 14(多类型公益活动)、Phase 15(活动报名、审核与签到)、Phase 16(活动互选与双方开启沟通确认)、Phase 17(举报与风控)、Phase 18(申诉)、Phase 19(统一运营后台 + 用户端公益服务页面)、Phase 20(Analytics 数据统计与管理驾驶舱)复验已关闭**,十四轮共修复 130+ 处真实缺陷(含 3 处隐藏编译缺陷),并各自补齐测试保真度与守卫。**Phase 21、22 复验已关闭(2026-09-18)**;**Phase 23 为「部分关闭」(2026-09-18)** —— 已清除 44 个 `pass` 空壳与 14 个只断言常量的假不变量测试,新增 4 个经负向注入验证的真实行为守卫与空壳阻断闸门,后端回归 655 全绿;但 **E2E 进入 CI 因需「活栈供给方式」基础设施决策而列为遗留,故不宣布「失败为零可进入 UAT」**。**Phase 23B(E2E 测试基建重建,33→49 passed)、HTTP 语义检修(失败响应携带真实状态码)、幂等键检修(`Idempotency-Key` 30 端点落地,V045/V046)均已于 2026-09-18 完成**,后端全量回归 **669 / 0 failures / 0 errors / 3 skipped**。**管理端入口已收敛到 `/admin/` 子路径(vite base + router base + 三处 nginx 接线),staging 端口 13521;UI 契约门禁 18/18 全绿(第 15~18 项为 2026-09-19 新增)。Phase 24(UAT)启动需用户确认。** **2026-09-19 收尾推进(批 2/3,已全部完成)**:Phase 23 Task 2.1(互选 8 + 预约 8 场景真实实现,本机 E2E **65 passed**,负向注入 2 轮精准变红)与 Task 2.2(INV-011 显式行为守卫 9 用例)完成;批 3 处置 Phase 21/22 遗留——隐私设置接真实落库(**V047** 补列 + upsert 修复,守卫 8 用例含解析小程序源码的契约守卫)、5 个死开关 + 2 个私有桶死配置移除(产品裁定)、`PUT /feature-flags` 真持久化(**V048**,守卫 11 用例)、补 5 个「只有声明无 SQL」的 Mapper 方法并新增绑定守卫;**Task 3.3 四项 + 红娘服务拦截接线完成**(P16-T13 `expires_at` 写入接线、P13A-T31 随访审计、P13A-T33 随访正文风险检测、P04-T10 确认 Onboarding 本地资格早已实现、`checkCanWithdraw` 红娘 blocker 首次生效 + 注销竞态兜底),各项均带真实落库守卫并经负向注入验证。**全量回归 712 / 0 failures / 0 errors / 3 skipped(BUILD SUCCESS,2026-09-19 实测)→ 717(2026-09-20 实测,数据保留流程 +5)**;UI 契约门禁 **18/18**;**E2E 本机模拟 CI 序列实测 65/65 全绿(2026-09-20,宿主机 Python 3.11 + 活栈,GitHub Actions 流水线实测仍待一次推送)**。批 3 仅余:E2E 入 CI 的 GitHub Actions 实测绿(数据保留流程已于 2026-09-19/20 落地,见 V049 行)。
最后更新: 2026-09-20 — 分支 `main`(`feat-ui-v11` 保留为冗余锚点) 最后更新: 2026-09-20 — 分支 `main`(V1.0.1 配置与规则纠偏落地,回归 733;`feat-ui-v11` 保留为冗余锚点)
## 当前唯一有效状态 ## 当前唯一有效状态
@@ -62,6 +62,7 @@
| 1.1.0-rc.18 | 2026-09-19 | **小程序两处运行期/编译期缺陷 + 再补 3 项门禁(15 → 18)**。① **P0 运行期崩溃**:`utils/api.js` 成功回调把 `releaseIdempotencyKey` 误写成 `this._releaseIdempotencyKey`(多一个下划线),且调用位于 `resolve()` **之前** → 抛错使 **每一个成功请求的 Promise 永不 settle** → 所有页面永远停在 loading + `App.onError` 刷屏。修复方式不仅是改名:改为**先 `resolve` 再释放**且释放自身 try/catch 兜底 —— 收尾动作不允许挡在响应路径上。② 组件 `recommend-card/index.wxss` 使用标签选择器 `.compact-like image`(组件 wxss 只允许类选择器)→ 改为 `.compact-like__icon` 并同步 WXML 类名。③ 新增门禁:`wxml-structure-contracts`(第 15)、`miniapp-self-method-contracts`(第 16,判据先自证假阳性 —— 首版正则漏认 `this._x = 值` 形式误报 3 个页面)、`wxss-component-selector-contracts`(第 17)、`miniapp-api-client-behavior`(第 18,**桩掉 `wx.request` 真跑 api.js** 的成功/失败/幂等键释放/收尾容错 4 组行为断言)。负向验证:注入「未定义私有方法」「标签选择器」「多余闭合标签」均精准变红且合法样例不被误报;`UI_GUARDS_MIN=99` 拒绝报绿。基线 **PASS 18/18**。**未验证**:微信开发者工具真实编译与真机渲染(本机无该环境) | | 1.1.0-rc.18 | 2026-09-19 | **小程序两处运行期/编译期缺陷 + 再补 3 项门禁(15 → 18)**。① **P0 运行期崩溃**:`utils/api.js` 成功回调把 `releaseIdempotencyKey` 误写成 `this._releaseIdempotencyKey`(多一个下划线),且调用位于 `resolve()` **之前** → 抛错使 **每一个成功请求的 Promise 永不 settle** → 所有页面永远停在 loading + `App.onError` 刷屏。修复方式不仅是改名:改为**先 `resolve` 再释放**且释放自身 try/catch 兜底 —— 收尾动作不允许挡在响应路径上。② 组件 `recommend-card/index.wxss` 使用标签选择器 `.compact-like image`(组件 wxss 只允许类选择器)→ 改为 `.compact-like__icon` 并同步 WXML 类名。③ 新增门禁:`wxml-structure-contracts`(第 15)、`miniapp-self-method-contracts`(第 16,判据先自证假阳性 —— 首版正则漏认 `this._x = 值` 形式误报 3 个页面)、`wxss-component-selector-contracts`(第 17)、`miniapp-api-client-behavior`(第 18,**桩掉 `wx.request` 真跑 api.js** 的成功/失败/幂等键释放/收尾容错 4 组行为断言)。负向验证:注入「未定义私有方法」「标签选择器」「多余闭合标签」均精准变红且合法样例不被误报;`UI_GUARDS_MIN=99` 拒绝报绿。基线 **PASS 18/18**。**未验证**:微信开发者工具真实编译与真机渲染(本机无该环境) |
| 1.1.0-rc.19 | 2026-09-19 | 批 2/3 收尾完成:E2E 互选 8 + 预约 8 场景真实实现(本机活栈 **65 passed**,负向注入 2 轮)+ INV-011 显式守卫(9 用例);隐私设置接真实落库(V047)+ 5 死开关/2 死配置移除(产品裁定)+ `PUT /feature-flags` 真持久化(V048)+ 5 个无 SQL Mapper 方法补齐;**Task 3.3 四项 + 红娘服务拦截接线**(互选 `expires_at`、随访审计、随访风险检测、Onboarding 确认、注销 blocker 接线 + 竞态兜底);UI 契约门禁 **14→18**(WXML 结构 / WXSS 选择器 / api.js 行为 / miniapp 请求层,并修复小程序 P0 请求层崩溃);全量回归 **712 / 0 / 0 / 3** 全绿(B-9 命令实测) | | 1.1.0-rc.19 | 2026-09-19 | 批 2/3 收尾完成:E2E 互选 8 + 预约 8 场景真实实现(本机活栈 **65 passed**,负向注入 2 轮)+ INV-011 显式守卫(9 用例);隐私设置接真实落库(V047)+ 5 死开关/2 死配置移除(产品裁定)+ `PUT /feature-flags` 真持久化(V048)+ 5 个无 SQL Mapper 方法补齐;**Task 3.3 四项 + 红娘服务拦截接线**(互选 `expires_at`、随访审计、随访风险检测、Onboarding 确认、注销 blocker 接线 + 竞态兜底);UI 契约门禁 **14→18**(WXML 结构 / WXSS 选择器 / api.js 行为 / miniapp 请求层,并修复小程序 P0 请求层崩溃);全量回归 **712 / 0 / 0 / 3** 全绿(B-9 命令实测) |
| 1.1.0-rc.20 | 2026-09-20 | **数据保留流程落地**(产品 2026-09-19 确认:注销后数据保留不得低于 36 个月):V049 `users.closed_at` + `account.retention.purge` 权限(仅 super_admin)+ `AccountConfig.dataRetentionMonths`(36,配置化)+ `DataRetentionService`(人工审核:到期清单 + 幂等匿名化清除 + 审计;保留期内服务端拒绝)+ `DataRetentionGuardTest` 5 用例(负向注入摘掉下限判断恰 1 红);顺带清偿测试库缺 `user_photos` 表(照片软删路径 BadSqlGrammar 盲区,补表 + 移出 ParityTest 可选白名单)与 MyBatis-Plus `updateById` null 不进 SQL 陷阱(改 `LambdaUpdateWrapper.set(null)`);隐私政策 §3.3 / PIA §8 「30 天删除」修订为 36 个月;迁移基线 48→49。**全量回归 717 / 0 / 0 / 3 全绿**;**E2E 本机模拟 CI 序列 65/65 全绿**(宿主机 Python 3.11);UAT 用例 V1.1 版 46 用例(9 角色 × 7 路径)编写完成 | | 1.1.0-rc.20 | 2026-09-20 | **数据保留流程落地**(产品 2026-09-19 确认:注销后数据保留不得低于 36 个月):V049 `users.closed_at` + `account.retention.purge` 权限(仅 super_admin)+ `AccountConfig.dataRetentionMonths`(36,配置化)+ `DataRetentionService`(人工审核:到期清单 + 幂等匿名化清除 + 审计;保留期内服务端拒绝)+ `DataRetentionGuardTest` 5 用例(负向注入摘掉下限判断恰 1 红);顺带清偿测试库缺 `user_photos` 表(照片软删路径 BadSqlGrammar 盲区,补表 + 移出 ParityTest 可选白名单)与 MyBatis-Plus `updateById` null 不进 SQL 陷阱(改 `LambdaUpdateWrapper.set(null)`);隐私政策 §3.3 / PIA §8 「30 天删除」修订为 36 个月;迁移基线 48→49。**全量回归 717 / 0 / 0 / 3 全绿**;**E2E 本机模拟 CI 序列 65/65 全绿**(宿主机 Python 3.11);UAT 用例 V1.1 版 46 用例(9 角色 × 7 路径)编写完成 |
| 1.1.0-rc.21 | 2026-09-20 | **V1.0.1 配置与规则纠偏**(根治「技术默认值被误当产品规则 / 配置存在但未闭环 / 文档代码漂移」):① **照片上限配置化** `profile.photo.maxCount`(默认 6 未产品确认)+ `ProfilePolicy` + 服务器端 `FOR UPDATE` 并发强校验(新增 `PHOTO_LIMIT_EXCEEDED` 码,拒绝先于对象存储上传不留孤儿)+ 前端 `mergeAppConfig` 嵌套键修复(服务端值真正下发);② **活体开关闭环** `IdentityPolicy` + `GET /verification/liveness-status` 真实判定点(默认关不阻断实名主流程,开则 `enhancedVerified` 变化,消除假开关)+ 删死 seed `livenessVerification.enabled`;③ **推荐阈值纠偏**(60 为技术默认非产品确认)+ 规则版本化 V050 `recommendation_records.threshold_snapshot` + 删死重复配置类 `MatchConfig`;④ 新增 `docs/governance/`(config-register / open-questions / decision-register / hardcoded-rule-audit / config-drift-audit)并消解 PENDING 阈值/照片矛盾;⑤ 守卫 `PhotoLimitGuardTest` 8 + `IdentityLivenessGuardTest` 5 + `RecommendationThresholdSnapshotGuardTest` 4(均经负向注入可红)。**全量回归 733 / 0 / 0 / 3 全绿(基线 717 + 17 新增 − 1 删死测试);UI 契约门禁 18/18** |
--- ---
+42
View File
@@ -0,0 +1,42 @@
# 配置漂移审计 — config-drift-audit.md
> **版本**:V1.0.1 配置与规则纠偏 · **日期**:2026-09-20
> **判定原则**:同一业务含义只允许**一套 canonical key**;不得出现「seed 写一套、真实系统读另一套」「前端写一套、后端又一套」「文档说一套、代码做一套」。
---
## 一、`system_configs` 是**死表**(0 个 Java 读取点)
- `V001__init_base_tables.sql:131` 建表,`:213-217` seed 了 `match_rule_version / daily_recommendation_limit / match_score_threshold='60' / recommendation_cooldown_days`。
- `demo-seed-final.sql` 又 seed 一批 flag 名(`localEligibility.evidencePolicy`、`counseling.enabled`、`premarital.enabled`、`serviceLocation.enabled`、`content.enabled`、`localEligibility.enabled`、`banner.maxActiveCount`)。
- **全仓 `backend/src/main/java` 无任何 `SystemConfig` 实体 / Mapper / 读取**(仅 `EntityMigrationParityTest` 把它列入 H2 白名单)。
- **后果**:真实系统读的是 `MatchRuleConfigProperties`(yml `match.*`)+ `FeatureFlagConfig`(`features.*` / `feature_flag_overrides`),**不是** `system_configs`。于是 `match_score_threshold='60'` 在死表里躺著,与活键 `match.default-score-threshold` 各写一份 → **seed 与 migration 漂移、seed 与代码漂移**。
- **处置**:本轮删除 `demo-seed-final.sql` 的死 key `livenessVerification.enabled`(与活键 `livenessEnabled` 同义双 key)。其余 `system_configs` 死行**未删**(表整体退役需单独迁移裁定,避免越界)——登记为未来项:整体退役 `system_configs` 或把它接成真实配置源。
## 二、`match_rule_configs` 是**未接线的规则版本化雏形**(死表)
- `V006` 建 `match_rule_configs`(version/status/10 权重/`minimum_score DEFAULT 60`),`MatchRuleConfigMapper.findActive/findByVersion` **无任何调用方**。
- 真实规则版本化本轮走轻量路线:`recommendation_records.rule_version` + 新列 `threshold_snapshot`(V050)+ `match.rule-version`。
- **处置**:登记为未来项 —— 要么把 `match_rule_configs` 接成「规则版本唯一读取源」(配合 open-questions 的 10 维权重配置化),要么删除该死表。本轮未动(避免越界)。
## 三、本轮已消除的 key/类漂移
| 漂移 | 处置 |
|------|------|
| seed `livenessVerification.enabled` vs 活键 `livenessEnabled`(双 key 同义) | 已删 seed 行;canonical `identity.liveness.enabled` 映射到唯一实现键 `livenessEnabled`(见 config-register)。 |
| `shared/config/MatchConfig` 与 `match/config/MatchRuleConfigProperties` 同前缀 `match` 双 bean(前者 0 业务引用) | 已删死类 `MatchConfig`,统一读 `MatchRuleConfigProperties`。 |
| 前端 `mergeAppConfig` 平铺合并 → 服务端嵌套 `businessLimits.maxProfilePhotos` 等**从不生效**(前端 6 写死、后端也不强校验) | 已修前端显式节-键映射 + 后端 `ProfilePolicy` 强校验 + `/app/config` 读 `ProfilePolicy`。前后端现共用同一配置源。 |
| 活体开关「假开关」(`livenessEnabled` 无业务强制点,0 读取点) | 已建 `IdentityPolicy` + `GET /verification/liveness-status` 真实判定点,开关开则 `enhancedVerified` 变化(FF-LIVENESS-002)。 |
## 四、剩余待登记漂移(未来项,勿本轮扩大)
| 项 | 说明 |
|----|------|
| `AppController` 剩余硬编码(dailyRecommendationLimit / maxInterestTags / selfIntroMaxLength / recommendationCooldownDays / reviewConfig / features) | 与 `MatchRuleConfigProperties` / 各配置源各写一份;待统一为「读配置源 + 经 `/app/config` 下发」(见 hardcoded-rule-audit 第二节)。 |
| 前端 `BUSINESS_LIMITS` 本地默认值 vs 服务端 | `maxProfilePhotos` 已打通;`DAILY_RECOMMENDATION_LIMIT / MAX_INTEREST_TAGS` 等仍为本地 fallback,服务端值经 `/app/config` 下发后应全部以服务端为准(前端只作失败兜底)。 |
| 打分公式占位(0.6/0.4、80/60、恒 50.0) | 非漂移,属 V1 占位未产品化;正式化时随规则版本一起配置化。 |
---
*结论:本轮消除了文档点名的 4 类漂移(liveness 双 key、MatchConfig 重复、前后端照片上限失同步、活体假开关)。
`system_configs` 死表、`match_rule_configs` 死表、AppController 剩余硬编码为已登记的未来项。*
+55
View File
@@ -0,0 +1,55 @@
# 配置登记表 — config-register.md
> **版本**:V1.0.1 配置与规则纠偏基线 · **生效**:2026-09-20 · **owner**:WorkBuddy
> **代码载体**:`FeatureFlagConfig`(feature flag,前缀 `features`)/ `MatchRuleConfigProperties`(前缀 `match`)/ `ProfileProperties`(前缀 `profile`)/ `AccountConfig`(前缀 `account`)
> **一致性守卫**:`FeatureFlagPersistenceGuardTest`、`MatchRuleConfigPropertiesTest`、`EntityMigrationParityTest`、`MigrationValidationTest`
>
> **核心纪律**(AGENTS.md #12 + V1.0.1 纠偏):**技术默认值 ≠ 产品正式确认规则。**
> 未确认的数量/阈值/时间/比例/权重/开关,一律按 `DEFAULT + CONFIG` 管理,禁止作为 `LOCKED` 业务规则写死;
> 所有配置必须有**唯一 canonical key**,禁止同一业务含义存在多套 key(seed / yml / 字段 / 前端各写一套)。
---
## 一、分类图例
| 分类 | 含义 | 是否可硬编码 |
|------|------|:---:|
| `LOCKED` | 产品负责人已确认的业务/安全硬约束 | 原则上不可随意变更 |
| `DEFAULT` | 当前技术实现默认值,可经配置/ADR 替换 | 不应散落硬编码 |
| `CONFIG` | 需配置化管理的业务参数/开关 | 禁止写死 |
| `TBD` | 产品尚未正式确认 | 禁止当成产品规则 |
| `FUTURE` | 明确不属于当前 V1 范围 | 不实现主链路 |
| `LEGAL_VERIFY` | 上线前需依最新官方/法律要求核验 | 不得自行假设 |
---
## 二、配置登记表
| canonical 配置键 | 实现键(yml / flag 字段) | 分类 | 当前默认值 | 产品正式确认 | 是否可配置 | 代码载体 / 说明 |
|------------------|--------------------------|------|:---------:|:---:|:---:|----------------|
| `identity.liveness.enabled` | `features.livenessEnabled`(flag 字段 `livenessEnabled`;yml `features.liveness-enabled`) | CONFIG / TBD | `false` | 否 | 是 | `FeatureFlagConfig` + `IdentityPolicy`;**活体不是 V1 强制规则**;默认关闭不阻断实名主流程;打开后经 `GET /verification/liveness-status` 真实驱动 `enhancedVerified`(FF-LIVENESS-002)。fail-safe:配置缺失默认 false。 |
| `identity.liveness.provider` | `features.livenessProvider`(yml `features.liveness-provider`) | CONFIG / TBD | `none` | 否 | 是 | `FeatureFlagConfig`;`none`=尚未接入合规第三方,人脸 Provider 选型未定(OQ-IDENTITY-001)。 |
| `profile.photo.maxCount` | `profile.photo.max-count` | CONFIG / TBD | `6` | 否 | 是 | `ProfileProperties` + `ProfilePolicy`;**6 仅为技术默认**;服务器端 `PhotoService.ensureWithinPhotoLimit`(FOR UPDATE 并发安全)强制校验;前端经 `GET /app/config` → `businessLimits.maxProfilePhotos` 动态读取,不再写死。 |
| `matching.recommendation.minScore` | `match.default-score-threshold` | CONFIG / TBD | `60` | 否 | 是 | `MatchRuleConfigProperties` + `MatchingPolicy`;**60 仅为技术默认,非产品确认**(消解 PENDING :43 vs :75 矛盾);引擎/资格判定读配置;推荐记录 V050 起写 `threshold_snapshot` 支持追溯。 |
| `matching.rule.version` | `match.rule-version` | CONFIG | `1` | 否 | 是 | `MatchRuleConfigProperties`;写入 `recommendation_records.rule_version`,与阈值快照配合构成规则版本。 |
| `matching.daily.limit` | `match.default-daily-limit` | CONFIG | `6` | 是(每日 6 人,见 decision-register) | 是 | `MatchRuleConfigProperties`。 |
| `matching.cooldown.days` | `match.default-cooldown-days` | CONFIG | `180` | 是(Pass 冷却 180 天) | 是 | `MatchRuleConfigProperties`。 |
| `matching.weights.*`(10 维) | `match.*-weight`(age 15 / region 15 / marital 10 / children 10 / education 5 / height 5 / marriagePlan 15 / settlement 10 / lifestyle 10 / interest 5) | CONFIG / TBD | 见左 | 否 | 是 | `MatchRuleConfigProperties`;yml 未覆盖,全用类内默认;**打分公式仍为 V1 占位**(见 audit 未来项)。 |
| `matching.local-eligibility.required` | `match.local-eligibility-required` | CONFIG | `false` | 否 | 是(仅 docker yml) | 本地资格是否作为推荐硬性门禁;默认关。 |
| `account.data-retention.months` | `account.data-retention-months` | LOCKED | `36` | 是(2026-09-19 产品确认下限 36 个月) | 是 | `AccountConfig`;注销后保留期下限,到期人工审核匿名化(V049)。 |
| `account.feature-flags.*`(活动/红娘/图片聊天/申诉/转介/互选/专项 开关) | `features.*` | CONFIG | 见 `FeatureFlagConfig` | 部分 | 是 | `FeatureFlagConfig`;`feature_flag_overrides`(V048)持久化覆盖。 |
| `idempotency.ttl` | 应用层 `idempotency_records`(TTL 默认 86400s) | CONFIG / LEGAL_VERIFY | `86400s` | 否 | 是 | 幂等去重窗口;开放第三方时需决定是否按调用方分级(OQ,见 open-questions)。 |
---
## 三、已消除的 key 漂移(本轮)
| 漂移 | 处置 |
|------|------|
| seed `livenessVerification.enabled` vs 实现键 `livenessEnabled`(两套 key 同义) | **已删** `demo-seed-final.sql` 的死 seed 行;canonical 名 `identity.liveness.enabled` 映射到唯一实现键 `livenessEnabled`(见上表)。 |
| `MatchConfig`(`shared/config`)与 `MatchRuleConfigProperties`(`match/config`)同前缀 `match` 双 bean | **已删** 死类 `MatchConfig`(0 业务引用),统一读 `MatchRuleConfigProperties`。 |
| 前端 `mergeAppConfig` 平铺合并 → 服务端嵌套 `businessLimits.*` 从不生效 | **已修** 显式节-键映射,`maxProfilePhotos` 等真正下发到前端。 |
---
*最后更新: 2026-09-20 — V1.0.1 配置与规则纠偏:登记 identity.liveness.enabled / profile.photo.maxCount / matching.recommendation.minScore 三项 canonical 键,消除 liveness 双 key 与 MatchConfig 重复。*
+43
View File
@@ -0,0 +1,43 @@
# 决策登记表 — decision-register.md
> **版本**:V1.0.1 配置与规则纠偏基线 · **生效**:2026-09-20 · **owner**:产品负责人
> **只登记产品负责人已正式确认的决策**(LOCKED / 已确认默认)。未确认项一律在
> `open-questions.md`,**不得**出现在本表(消解 PENDING「同一事项既待确认又已确认」矛盾)。
> 确认值同步记入 `config-register.md` 的「产品正式确认」列。
---
## 已确认决策
| 类别 | 确认项 | 分类 | 代码载体 / 备注 |
|------|--------|:---:|----------------|
| 品牌 | 善缘丹阳品牌正式启用 | LOCKED | 品牌资源统一走 `admin-web/src/brand.ts` 的 `BASE_URL` 拼接 |
| 首页 | Banner 运营 + 8 个核心入口 + 固定顺序 | LOCKED | INV-017 顺序固定,运营不可重排;Banner 仅内部跳转(INV-016) |
| 数据保留 | **注销后用户数据保留不得低于 36 个月**(2026-09-19 产品确认) | LOCKED | `account.data-retention-months`(默认 36);到期人工审核匿名化清除(V049 `users.closed_at` + `DataRetentionService` + `account.retention.purge`);隐私政策 §3.3 / PIA §8 已修订 |
| 服务站 | 万善公园婚姻登记处为固定阵地 | LOCKED | `service_locations` 种子数据 |
| 服务 | 婚恋咨询 + 婚前辅导进入 V1.1 首发 | LOCKED | counseling / premarital 模块 |
| 内容 | 婚恋课堂 + 婚恋反诈进入 V1.1 首发 | LOCKED | content 模块 |
| 活动 | 多类型公益活动 + 活动双向沟通确认进入 V1.1 首发 | LOCKED | 二阶段同意(INV-014/015) |
| 核心规则 | Required 不可放宽 / Mutual 后才 Chat / Block 优先 | LOCKED | 匹配/聊天不变量 |
| 推荐 | 每日 6 人 / Pass 冷却 180 天 | LOCKED | `match.default-daily-limit=6`、`match.default-cooldown-days=180` |
| 资料 | 兴趣标签上限 8 个 / 自我介绍 500 字符 | LOCKED | 前端常量 + 后端校验 |
| 隐私 | Privacy 默认全 TRUE | LOCKED | `user_privacy_settings` |
| 活动规则 | 需审核 / 男女名额独立配置 / 互选最大 3 人 / 截止后 48 小时 | LOCKED | activity 模块 |
| 关系状态 | searching / getting_to_know / paused / matched_successfully / service_exited | LOCKED | 关系状态枚举 |
| 推荐重置 | 每日 00:00 Asia/Shanghai | LOCKED | 时区统一 Asia/Shanghai |
---
## 明确「非」已确认(防止误登记,OQ 见 open-questions.md)
| 事项 | 正确定位 | 参见 |
|------|----------|------|
| 照片上限 6 | **技术默认,未产品确认**(OQ-PROFILE-001) | open-questions |
| 推荐阈值 60 | **技术默认,未产品确认**(OQ-MATCH-001) | open-questions |
| 活体核验强制 | **V1.1 首发不强制**,Flag 默认关(OQ-IDENTITY-001) | open-questions |
| Banner 最大活跃数量 | **未定 + 代码未实现**(OQ-BANNER-001,真缺口) | open-questions |
| 有效匹配报送口径 | **PROVISIONAL,未产品裁决**(OQ-ANALYTICS-001) | open-questions |
---
*最后更新: 2026-09-20 — 登记 15 项已确认决策;照片 6 / 阈值 60 / 活体 / Banner / 报送口径 明确划为非确认项,转入 open-questions。*
+45
View File
@@ -0,0 +1,45 @@
# 硬编码规则审计 — hardcoded-rule-audit.md
> **版本**:V1.0.1 配置与规则纠偏 · **日期**:2026-09-20 · **扫描**:业务代码数值/常量 + `MAX_/LIMIT_/THRESHOLD_/COOLDOWN_/TIMEOUT_/COUNT_/SCORE_`
> **定级**:LOCKED(产品确认)/ DEFAULT(技术默认)/ CONFIG(应配置化)/ TBD(未确认)/ FUTURE(非 V1 范围)
> **判定原则**:未确认数量参数不得作为固定业务常量写死(AGENTS.md #12);必须存在运行初值时,定义为 `DEFAULT + CONFIG` 并登记 `config-register.md`。
---
## 一、已配置化且被真实读取(本轮/历史已合规)
| 规则 | 载体 | 值 | 定级 |
|------|------|----|------|
| 每日推荐上限 | `MatchRuleConfigProperties.defaultDailyLimit`(yml `match.default-daily-limit`) | 6 | LOCKED(已确认每日 6 人) |
| Pass 冷却天数 | `match.default-cooldown-days` | 180 | LOCKED(已确认) |
| 推荐最低阈值 | `match.default-score-threshold` → `MatchingPolicy` | 60 | CONFIG / **TBD**(技术默认,未确认,OQ-MATCH-001) |
| 匹配规则版本 | `match.rule-version` | 1 | CONFIG |
| 10 维推荐权重 | `match.*-weight` | age15/region15/… | CONFIG / TBD(yml 未覆盖,全用类内默认) |
| 照片上限 | `profile.photo.max-count` → `ProfilePolicy` | 6 | CONFIG / **TBD**(技术默认,未确认,OQ-PROFILE-001;服务器端强校验) |
| 注销保留期 | `account.data-retention-months` | 36 | LOCKED(2026-09-19 产品确认) |
| 本地资格佐证参数 | `features.localEvidence*`(保留 365 天/5 文件/10MB/jpg,png) | 见左 | CONFIG(保留期 LEGAL_VERIFY,OQ-LOCAL-ELIG-001) |
| 活体开关 | `features.livenessEnabled/livenessProvider` → `IdentityPolicy` | false / none | CONFIG / TBD(OQ-IDENTITY-001) |
| 幂等 TTL | `idempotency_records`(默认 86400s) | 86400s | CONFIG / LEGAL_VERIFY(第三方分级待定) |
## 二、仍为硬编码 —— 本轮**未纳入**,登记为「待统一」未来项
> 本轮按约束 12 只纠偏文档点名的 3 项(活体键 / 照片上限 / 推荐阈值)。以下为同族但**未动**,避免越界:
| 位置 | 硬编码 | 建议 |
|------|--------|------|
| `AppController` `/app/config` | `dailyRecommendationLimit=6`、`maxInterestTags=8`、`selfIntroMaxLength=500`、`recommendationCooldownDays=180`、`reviewConfig{autoApproveAfterDays=7,maxResubmitCount=3}`、`features` 全 true | 统一改读 `MatchRuleConfigProperties` / 对应配置,前端经 `/app/config` 下发;值确认后转 LOCKED |
| `RecommendationEngineService` / `MatchEligibilityService` | 互选公式系数 `0.6*min + 0.4*avg`(:230/:122)、level 分档 `>=80 high / >=60 medium`(:392-396/:262-266)、`calculateScore` 恒返回 `50.0`(V1 占位) | **打分是 V1 占位**,公式/分档/系数尚未产品化;正式化时一并配置化 + 记入规则版本 |
| `PhotoService` | 图片大小上限 10MB、签名 URL 24h、上传凭证 1h(`SIGNED_URL_EXPIRE_HOURS`) | 属对象存储/S3 参数,非业务规则;如需可调再配置化(低优先级) |
## 三、常量类扫描(非业务数值,多为校验/分页,合规)
| 常量 | 说明 |
|------|------|
| `MAX_LENGTH`(42 处) | 字段长度校验上限,属校验约束,非「未确认业务数量」 |
| `MAX_PAGE_SIZE` / `MAX_VALUE` / `MAX_PER_MINUTE*` / `MAX_LOGIN_ATTEMPTS` | 分页/速率/登录防爆破,合理技术上限 |
| `PHOTO_LIMIT_EXCEEDED`(本轮新增业务码) | 照片超限业务错误码,非数值 |
---
*结论:本轮已将文档点名的 3 项(活体键、照片上限、推荐阈值)落实为「配置源唯一 + 真实读取 + 默认值受控」。
上表第二节为同族未动项,建议在后续 Phase 按同一纪律统一(勿一次性扩大重构)。*
+76
View File
@@ -0,0 +1,76 @@
# 开放问题 — open-questions.md
> **版本**:V1.0.1 配置与规则纠偏基线 · **生效**:2026-09-20 · **owner**:产品负责人
> 未决项在此登记;**产品正式确认后**移入 `decision-register.md`,并同步更新
> `config-register.md` 的「产品正式确认」列与 `PENDING_DECISIONS.md`。
> 状态:`TBD`(待产品裁决)/ `IN_PROGRESS` / `RESOLVED`。
---
## OQ-IDENTITY-001 · 是否在正式运营阶段启用人脸/活体核验?
- **状态**:TBD
- **当前技术默认**:`identity.liveness.enabled = false`(`features.livenessEnabled`),provider = `none`
- **说明**:活体**不是 V1.1 首发强制规则**。默认关闭时不得阻断实名主流程、不得误提示「必须人脸」。
本轮已把开关接到真实业务判定点(`GET /verification/liveness-status` → `enhancedVerified`),
消除「假开关」。是否启用、选用哪家合规第三方 Provider(`OQ` 选型未定)由产品/合规裁决。
- **关联**:`IdentityPolicy`、`UserIdentity.livenessVerificationStatus`、V016。
## OQ-PROFILE-001 · 用户最多可上传多少张个人照片?
- **状态**:TBD
- **当前技术默认**:`profile.photo.maxCount = 6`(头像 + 生活照合计)
- **说明**:6 仅作为技术默认值,**必须保持配置化**(已配置化 + 服务器端强校验 + 并发安全)。
不得写死;产品确认正式值后改配置即可,无需改代码。
- **关联**:`ProfileProperties` / `ProfilePolicy` / `PhotoService.ensureWithinPhotoLimit`。
## OQ-MATCH-001 · 推荐最低评分阈值最终采用多少?
- **状态**:TBD
- **当前技术默认**:`matching.recommendation.minScore = 60`(`match.default-score-threshold`)
- **说明**:60 **不代表产品正式确认**。阈值已配置化;推荐记录自 V050 起携带
`threshold_snapshot` + `rule_version`,未来 60→65 可追溯历史推荐依据。
前端不得把 score/threshold 包装为「匹配成功率 / 合适概率」等对外承诺。
- **关联**:`MatchRuleConfigProperties` / `MatchingPolicy` / V050。
## OQ-LOCAL-ELIG-001 · 本地资格佐证文件保留期限?
- **状态**:TBD(需合规审核)
- **当前技术默认**:`features.localEvidenceRetentionDays = 365`(可配置,非永久)
- **说明**:与「注销后 36 个月数据保留」(LOCKED)是**两套独立机制**。
配套清理作业尚未实现(已挂 Phase 22 运维任务,Phase-04 报告 :46 登记)。
## OQ-SERVICE-LOC-001 · 善缘服务站正式开放时间?
- **状态**:TBD
- **当前实现**:`service_locations.opening_hours_text` 自由文本(数据驱动、非硬编码),
种子值「周一至周五 9:00-17:00 / 周六 9:00-12:00」。属产品文案决策,挂起不动代码。
## OQ-COUNSEL-001 · 咨询人员实际资质要求 / 预约时段规则?
- **状态**:TBD
- **当前实现**:代码中**无资质字段/校验**(现状即「无资质要求」安全默认);预约时段为自由输入
+ 运营手工确认(无自动时段校验,不违反 AGENTS #12)。未来需资质/自动时段才需产品出规则并配置化。
## OQ-BANNER-001 · Banner 最大活跃数量?
- **状态**:TBD(**真缺口**)
- **当前实现**:代码**无**该限制,仅 demo seed 里一个从未被读取的死值 `banner.maxActiveCount='5'`
(`system_configs` 为死表,0 个 Java 读取点)。需产品定值后**实现** active-count 校验。
## OQ-ANALYTICS-001 · 有效交友匹配正式报送口径?
- **状态**:TBD
- **当前实现**:`match.effective` 为 `PROVISIONAL` 保守近似(`min(有真人消息会话, Match 总数)`),
文档已标「禁止对外」。建议口径(Mutual Match + Two-Way Chat)未实现;升 `OFFICIAL` 前需产品裁决。
## OQ-THIRD-PARTY-* · 第三方开放 / 平台化遗留(7 项)
- **状态**:TBD(范围扩张,需产品决策,本轮未擅自实施)
- 详见 `PENDING_DECISIONS.md`「第三方开放 / 平台化遗留待决策项」:第三方凭据机制、限流是否真实生效、
OpenAPI 对外发布面、`POST /activities/{id}/interests` 语义、第三方调用审计与配额计量、
敏感字段脱敏边界(第三方场景)、幂等键 TTL 是否按调用方分级。
---
*最后更新: 2026-09-20 — 登记 OQ-IDENTITY/PROFILE/MATCH-001 及本地资格保留期、服务站时间、咨询资质、Banner 数量、报送口径、第三方开放遗留。*
+40 -6
View File
@@ -46,11 +46,45 @@ exports.BUSINESS_LIMITS = {
}; };
/** /**
* 用服务端返回的配置覆盖默认值(规范 §118) * 服务端 /app/config 的嵌套结构 → 客户端 UPPER_SNAKE 常量的显式映射。
* @param {Partial<typeof exports.BUSINESS_LIMITS>} overrides 服务端配置 *
* @returns {typeof exports.BUSINESS_LIMITS} 合并后的配置 * <p>此前用平铺合并 {@code {...BUSINESS_LIMITS, ...overrides}},命不中嵌套 key
* (服务端返回的是 {@code businessLimits.maxProfilePhotos} / {@code features.x}),
* 导致服务端下发的照片上限 / 推荐阈值等配置<b>从不生效</b> —— 前后端规则漂移
* (V1.0.1 配置纠偏 PROFILE-001 前端侧根因)。现改为按节(section)+ 键显式映射,
* 且仅在「服务端给了该键 && 客户端确有该常量」时才覆盖,缺省回退本地默认值。
*/ */
exports.mergeAppConfig = (overrides) => { const SERVER_SECTION_TO_CLIENT_KEY = {
if (!overrides || typeof overrides !== 'object') return exports.BUSINESS_LIMITS; businessLimits: {
return { ...exports.BUSINESS_LIMITS, ...overrides }; dailyRecommendationLimit: 'DAILY_RECOMMENDATION_LIMIT',
maxProfilePhotos: 'MAX_PROFILE_PHOTOS',
maxInterestTags: 'MAX_INTEREST_TAGS',
selfIntroMaxLength: 'MAX_INTRO_LENGTH',
},
features: {
activityEnabled: 'ACTIVITY_ENABLED',
matchmakerEnabled: 'MATCHMAKER_ENABLED',
imageChatEnabled: 'CHAT_IMAGE_ENABLED',
},
};
/**
* 用服务端返回的配置覆盖默认值(规范 §118)
* @param {object} serverConfig 服务端 /app/config 的完整响应(含 businessLimits / features 等嵌套节)
* @returns {typeof exports.BUSINESS_LIMITS} 合并后的运行时配置
*/
exports.mergeAppConfig = (serverConfig) => {
const merged = { ...exports.BUSINESS_LIMITS };
if (!serverConfig || typeof serverConfig !== 'object') return merged;
for (const [section, keyMap] of Object.entries(SERVER_SECTION_TO_CLIENT_KEY)) {
const src = serverConfig[section];
if (!src || typeof src !== 'object') continue;
for (const [serverKey, clientKey] of Object.entries(keyMap)) {
if (Object.prototype.hasOwnProperty.call(src, serverKey)
&& Object.prototype.hasOwnProperty.call(merged, clientKey)) {
merged[clientKey] = src[serverKey];
}
}
}
return merged;
}; };
+17 -1
View File
@@ -33,9 +33,25 @@ Page({
onLoad() { onLoad() {
this._initSystemInfo(); this._initSystemInfo();
this._applyPhotoLimit();
this.loadPhotos(); this.loadPhotos();
}, },
onShow() {
// 服务端配置(appStore.businessConfig)可能在冷启动后才拉取完成,onShow 再取一次
this._applyPhotoLimit();
},
/**
* 照片上限读运行时配置(服务端下发,经 appStore.businessConfig 覆盖本地默认),
* 前端不写死 6(PROFILE-001)。生活照槽位 = 总上限 - 1(头像占 1 张)。
*/
_applyPhotoLimit() {
const maxTotal = (appStore.state.businessConfig && appStore.state.businessConfig.MAX_PROFILE_PHOTOS)
|| BUSINESS_LIMITS.MAX_PROFILE_PHOTOS;
this.setData({ maxLifestyle: Math.max(0, maxTotal - 1) });
},
_initSystemInfo() { _initSystemInfo() {
try { try {
const sysInfo = wx.getSystemInfoSync(); const sysInfo = wx.getSystemInfoSync();
@@ -70,7 +86,7 @@ Page({
onAddLifestyle() { onAddLifestyle() {
const remaining = this.data.maxLifestyle - this.data.lifestylePhotos.length; const remaining = this.data.maxLifestyle - this.data.lifestylePhotos.length;
if (remaining <= 0) { if (remaining <= 0) {
appStore.toast(`最多上传 ${BUSINESS_LIMITS.MAX_PROFILE_PHOTOS} 张照片`); appStore.toast(`最多上传 ${this.data.maxLifestyle + 1} 张照片`);
return; return;
} }
this._chooseAndUpload('lifestyle'); this._chooseAndUpload('lifestyle');