diff --git a/backend/src/main/java/com/danyang/match/platform/AppController.java b/backend/src/main/java/com/danyang/match/platform/AppController.java index 60eb613..c180871 100644 --- a/backend/src/main/java/com/danyang/match/platform/AppController.java +++ b/backend/src/main/java/com/danyang/match/platform/AppController.java @@ -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 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); // 审核相关 diff --git a/backend/src/main/java/com/danyang/match/platform/match/entity/RecommendationRecord.java b/backend/src/main/java/com/danyang/match/platform/match/entity/RecommendationRecord.java index d7bcb5b..56b0a22 100644 --- a/backend/src/main/java/com/danyang/match/platform/match/entity/RecommendationRecord.java +++ b/backend/src/main/java/com/danyang/match/platform/match/entity/RecommendationRecord.java @@ -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; } diff --git a/backend/src/main/java/com/danyang/match/platform/match/service/RecommendationEngineService.java b/backend/src/main/java/com/danyang/match/platform/match/service/RecommendationEngineService.java index 3f6f7f1..2b318cc 100644 --- a/backend/src/main/java/com/danyang/match/platform/match/service/RecommendationEngineService.java +++ b/backend/src/main/java/com/danyang/match/platform/match/service/RecommendationEngineService.java @@ -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); } diff --git a/backend/src/main/java/com/danyang/match/platform/match/service/RecommendationService.java b/backend/src/main/java/com/danyang/match/platform/match/service/RecommendationService.java index e4fc482..03d4382 100644 --- a/backend/src/main/java/com/danyang/match/platform/match/service/RecommendationService.java +++ b/backend/src/main/java/com/danyang/match/platform/match/service/RecommendationService.java @@ -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); } diff --git a/backend/src/main/java/com/danyang/match/platform/photo/mapper/UserPhotoMapper.java b/backend/src/main/java/com/danyang/match/platform/photo/mapper/UserPhotoMapper.java index e075d48..091ccf4 100644 --- a/backend/src/main/java/com/danyang/match/platform/photo/mapper/UserPhotoMapper.java +++ b/backend/src/main/java/com/danyang/match/platform/photo/mapper/UserPhotoMapper.java @@ -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 { @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 的个人照片(头像 + 生活照,未软删)。 + * + *

用 {@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 lockActiveProfilePhotoIds(@Param("userId") String userId); } diff --git a/backend/src/main/java/com/danyang/match/platform/photo/service/PhotoService.java b/backend/src/main/java/com/danyang/match/platform/photo/service/PhotoService.java index 1efdd32..db1564d 100644 --- a/backend/src/main/java/com/danyang/match/platform/photo/service/PhotoService.java +++ b/backend/src/main/java/com/danyang/match/platform/photo/service/PhotoService.java @@ -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); } + /** + * 个人照片数量上限校验(服务器端强制,前端提示不可绕过)。 + * + *

须在 {@code @Transactional} 事务内调用(调用方 {@link #savePhoto} / + * {@link #uploadAndSave} 均已标注)。用 {@code SELECT ... FOR UPDATE} 锁定该用户 + * active 个人照片的行,使同用户并发上传被串行化:先提交的事务释放锁后,后到的事务 + * 锁上等待再重读最新数量,从而并发也不可突破上限(PHOTO-004)。 + * + *

规则(PROFILE-001 / OQ-PROFILE-001):个人照片(头像 + 生活照)合计须 ≤ + * {@code profile.photo.maxCount}(当前技术默认 6,未获产品正式确认,见 config-register)。 + *

+ */ + 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() @@ -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 { diff --git a/backend/src/main/java/com/danyang/match/platform/shared/config/MatchConfig.java b/backend/src/main/java/com/danyang/match/platform/shared/config/MatchConfig.java deleted file mode 100644 index d3655d8..0000000 --- a/backend/src/main/java/com/danyang/match/platform/shared/config/MatchConfig.java +++ /dev/null @@ -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; - } -} diff --git a/backend/src/main/java/com/danyang/match/platform/shared/config/ProfileProperties.java b/backend/src/main/java/com/danyang/match/platform/shared/config/ProfileProperties.java new file mode 100644 index 0000000..3d63fa8 --- /dev/null +++ b/backend/src/main/java/com/danyang/match/platform/shared/config/ProfileProperties.java @@ -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)配置。 + * + *

配置化纪律(AGENTS.md #12:未确认数量参数不得硬编码):照片上限 + * {@code profile.photo.maxCount} 当前技术默认 6,并非产品正式确认值(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; + } +} diff --git a/backend/src/main/java/com/danyang/match/platform/shared/enums/ApiResponseCode.java b/backend/src/main/java/com/danyang/match/platform/shared/enums/ApiResponseCode.java index 3148507..64c1b2d 100644 --- a/backend/src/main/java/com/danyang/match/platform/shared/enums/ApiResponseCode.java +++ b/backend/src/main/java/com/danyang/match/platform/shared/enums/ApiResponseCode.java @@ -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; diff --git a/backend/src/main/java/com/danyang/match/platform/shared/policy/DefaultIdentityPolicy.java b/backend/src/main/java/com/danyang/match/platform/shared/policy/DefaultIdentityPolicy.java new file mode 100644 index 0000000..054275b --- /dev/null +++ b/backend/src/main/java/com/danyang/match/platform/shared/policy/DefaultIdentityPolicy.java @@ -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})读取。 + * + *

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(); + } +} diff --git a/backend/src/main/java/com/danyang/match/platform/shared/policy/DefaultMatchingPolicy.java b/backend/src/main/java/com/danyang/match/platform/shared/policy/DefaultMatchingPolicy.java new file mode 100644 index 0000000..ef42207 --- /dev/null +++ b/backend/src/main/java/com/danyang/match/platform/shared/policy/DefaultMatchingPolicy.java @@ -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})读取当前生效阈值与规则版本。 + * + *

本类是「统一读取入口」,不新增配置源 —— 阈值/版本仍以 + * {@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(); + } +} diff --git a/backend/src/main/java/com/danyang/match/platform/shared/policy/DefaultProfilePolicy.java b/backend/src/main/java/com/danyang/match/platform/shared/policy/DefaultProfilePolicy.java new file mode 100644 index 0000000..8e531d9 --- /dev/null +++ b/backend/src/main/java/com/danyang/match/platform/shared/policy/DefaultProfilePolicy.java @@ -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(); + } +} diff --git a/backend/src/main/java/com/danyang/match/platform/shared/policy/IdentityPolicy.java b/backend/src/main/java/com/danyang/match/platform/shared/policy/IdentityPolicy.java new file mode 100644 index 0000000..9787d8d --- /dev/null +++ b/backend/src/main/java/com/danyang/match/platform/shared/policy/IdentityPolicy.java @@ -0,0 +1,23 @@ +package com.danyang.match.platform.shared.policy; + +/** + * 实名 / 活体核验规则统一读取入口(CFG-001 + IDENTITY-001,V1.0.1 配置与规则纠偏)。 + * + *

活体核验不是 V1 强制规则:canonical 配置键 {@code identity.liveness.enabled} + * (实现键 {@code features.livenessEnabled},见 config-register)默认 {@code false}。 + * 开关关闭时不得阻断实名主流程、不得要求用户完成活体(FF-LIVENESS-001/004)。 + * + *

本接口是「假开关」纠偏后的真实业务判定点:实名链路经此决定是否需要活体, + * 开关打开时业务行为确实改变(FF-LIVENESS-002),不存在「配置打开但业务无变化」。 + */ +public interface IdentityPolicy { + + /** + * 是否强制活体核验。由 Feature Flag {@code livenessEnabled} 驱动(默认 false,fail-safe)。 + * 配置缺失时保持默认 false,绝不自动开启。 + */ + boolean isLivenessRequired(); + + /** 当前活体核验 Provider 标识(none / third-party)。none 表示尚未接入合规第三方。 */ + String getLivenessProvider(); +} diff --git a/backend/src/main/java/com/danyang/match/platform/shared/policy/MatchingPolicy.java b/backend/src/main/java/com/danyang/match/platform/shared/policy/MatchingPolicy.java new file mode 100644 index 0000000..12f8a53 --- /dev/null +++ b/backend/src/main/java/com/danyang/match/platform/shared/policy/MatchingPolicy.java @@ -0,0 +1,24 @@ +package com.danyang.match.platform.shared.policy; + +/** + * 推荐 / 匹配规则统一读取入口。 + * + *

业务代码(Controller 下发、推荐引擎、资格判定)不得各自写死阈值数值, + * 一律经本接口取「当前生效值」,使配置源唯一({@code MatchRuleConfigProperties}, + * yml 键 {@code match.default-score-threshold} / {@code match.rule-version})。 + * + *

阈值 60 仅为当前技术默认值,未获产品正式确认(OQ-MATCH-001 / config-register + * 中 canonical 名 {@code matching.recommendation.minScore})。前端不得将其包装为 + * 「匹配成功率 / 合适概率」等对外承诺。 + */ +public interface MatchingPolicy { + + /** + * 推荐最低评分阈值。技术默认 60,未获产品正式确认;可经 + * {@code match.default-score-threshold} 配置覆盖。 + */ + int getRecommendationMinScore(); + + /** 匹配规则版本号(写入推荐记录以支持历史追溯)。 */ + int getRuleVersion(); +} diff --git a/backend/src/main/java/com/danyang/match/platform/shared/policy/ProfilePolicy.java b/backend/src/main/java/com/danyang/match/platform/shared/policy/ProfilePolicy.java new file mode 100644 index 0000000..89713af --- /dev/null +++ b/backend/src/main/java/com/danyang/match/platform/shared/policy/ProfilePolicy.java @@ -0,0 +1,16 @@ +package com.danyang.match.platform.shared.policy; + +/** + * 个人资料规则统一读取入口。 + * + *

业务代码(Controller / Service / 前端下发配置)不得各自写死照片上限数值, + * 一律经本接口取「当前生效值」,使配置源唯一({@code ProfileProperties})。 + */ +public interface ProfilePolicy { + + /** + * 个人照片最大数量(头像 + 生活照合计)。 + * 技术默认 6,未获产品正式确认;可经 {@code profile.photo.max-count} 配置覆盖。 + */ + int getMaxPhotoCount(); +} diff --git a/backend/src/main/java/com/danyang/match/platform/verification/controller/IdentityVerificationController.java b/backend/src/main/java/com/danyang/match/platform/verification/controller/IdentityVerificationController.java index dd35a7e..1494df2 100644 --- a/backend/src/main/java/com/danyang/match/platform/verification/controller/IdentityVerificationController.java +++ b/backend/src/main/java/com/danyang/match/platform/verification/controller/IdentityVerificationController.java @@ -62,6 +62,21 @@ public class IdentityVerificationController { return ApiResponse.ok(identityVerificationService.getIdentityStatus(userId)); } + /** + * 查询活体核验状态(V1.0.1 配置纠偏 IDENTITY-001:真实业务判定点)。 + * GET /api/v1/verification/liveness-status + * + *

返回 {@code livenessRequired}(由 Feature Flag 驱动,默认 false)、 + * {@code livenessVerificationStatus}(服务器事实)、{@code enhancedVerified}。 + * 开关关闭时不影响实名主流程;前端不得据此误提示「必须进行人脸认证」。 + */ + @GetMapping("/liveness-status") + public ApiResponse> getLivenessStatus( + @AuthenticationPrincipal UserPrincipal principal) { + String userId = principal.id(); + return ApiResponse.ok(identityVerificationService.getLivenessStatus(userId)); + } + /** * 撤销实名(仅 failed 状态可撤销) * POST /api/v1/verification/cancel diff --git a/backend/src/main/java/com/danyang/match/platform/verification/service/IdentityVerificationService.java b/backend/src/main/java/com/danyang/match/platform/verification/service/IdentityVerificationService.java index 60ed3af..1651f1e 100644 --- a/backend/src/main/java/com/danyang/match/platform/verification/service/IdentityVerificationService.java +++ b/backend/src/main/java/com/danyang/match/platform/verification/service/IdentityVerificationService.java @@ -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 配置纠偏)。 + * + *

开关({@code identity.liveness.enabled})打开时,增强核验需要活体通过; + * 活体状态取自服务器事实 {@code user_identities.liveness_verification_status}, + * 前端伪造的「已通过」无效(FF-LIVENESS-005)。 + * + *

    + *
  • FF-LIVENESS-001:开关关闭 → {@code livenessRequired=false},不阻断实名主流程;
  • + *
  • FF-LIVENESS-002:开关打开 → {@code livenessRequired=true},未活体通过则 + * {@code enhancedVerified=false}(业务状态确实改变,非假开关);
  • + *
  • FF-LIVENESS-004:配置缺失 → 沿用默认 false,不自动开启。
  • + *
+ */ + public Map 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 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 状态) */ diff --git a/backend/src/main/resources/application-docker.yml b/backend/src/main/resources/application-docker.yml index 9c0db32..54c1478 100644 --- a/backend/src/main/resources/application-docker.yml +++ b/backend/src/main/resources/application-docker.yml @@ -47,6 +47,11 @@ match: default-cooldown-days: 180 local-eligibility-required: ${MATCH_LOCAL_ELIGIBILITY_REQUIRED:false} +# Profile(个人照片上限:技术默认 6,未获产品正式确认;经环境变量可覆盖) +profile: + photo: + max-count: ${PROFILE_PHOTO_MAX_COUNT:6} + activity: default-need-review: true default-mutual-max-selection: 3 diff --git a/backend/src/main/resources/db/demo-seed-final.sql b/backend/src/main/resources/db/demo-seed-final.sql index 230768e..8582a50 100644 --- a/backend/src/main/resources/db/demo-seed-final.sql +++ b/backend/src/main/resources/db/demo-seed-final.sql @@ -449,7 +449,6 @@ DROP TABLE IF EXISTS _conv_result; -- V1.1 Feature Flag 初始化 -- ============================================================ INSERT INTO system_configs (config_key, config_value, description) VALUES -('livenessVerification.enabled', 'false', '人脸核验功能开关(V1.1预留,默认关闭)'), ('localEligibility.evidencePolicy', '"need_based"', '本地资格佐证策略:need_based=按需触发 / mandatory=全部强制'), ('counseling.enabled', 'true', '婚恋咨询功能开关'), ('premarital.enabled', 'true', '婚前辅导功能开关'), diff --git a/backend/src/main/resources/db/migration/V050__add_recommendation_threshold_snapshot.sql b/backend/src/main/resources/db/migration/V050__add_recommendation_threshold_snapshot.sql new file mode 100644 index 0000000..70c51b5 --- /dev/null +++ b/backend/src/main/resources/db/migration/V050__add_recommendation_threshold_snapshot.sql @@ -0,0 +1,17 @@ +-- V050: 推荐记录补「阈值快照」列(MATCH-001,V1.0.1 配置与规则纠偏) +-- +-- 背景:推荐最低评分阈值 60 是技术默认值(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 追溯历史推荐依据的规则版本。'; diff --git a/backend/src/test/java/com/danyang/match/platform/deployment/MigrationValidationTest.java b/backend/src/test/java/com/danyang/match/platform/deployment/MigrationValidationTest.java index 8f5a889..97cc5e4 100644 --- a/backend/src/test/java/com/danyang/match/platform/deployment/MigrationValidationTest.java +++ b/backend/src/test/java/com/danyang/match/platform/deployment/MigrationValidationTest.java @@ -27,7 +27,9 @@ class MigrationValidationTest { // (此前只改内存单例,重启即丢、多实例不一致) // 数据保留(2026-09-19 产品确认保留期下限 36 个月): // V049 users.closed_at(保留期起算点,此前注销时间只存在于日志)+ account.retention.purge 权限 - private static final int LATEST_MIGRATION = 49; + // V1.0.1 配置与规则纠偏(MATCH-001):V050 recommendation_records.threshold_snapshot + // —— 推荐阈值 60 为技术默认(未产品确认),记录当次阈值快照 + rule_version 以支持历史追溯 + private static final int LATEST_MIGRATION = 50; private static final String MIGRATION_DIR = "backend/src/main/resources/db/migration/"; diff --git a/backend/src/test/java/com/danyang/match/platform/match/service/RecommendationThresholdSnapshotGuardTest.java b/backend/src/test/java/com/danyang/match/platform/match/service/RecommendationThresholdSnapshotGuardTest.java new file mode 100644 index 0000000..0bc02f7 --- /dev/null +++ b/backend/src/test/java/com/danyang/match/platform/match/service/RecommendationThresholdSnapshotGuardTest.java @@ -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 配置与规则纠偏)。 + * + *

验证:① 阈值 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 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"); + } +} diff --git a/backend/src/test/java/com/danyang/match/platform/photo/PhotoLimitGuardTest.java b/backend/src/test/java/com/danyang/match/platform/photo/PhotoLimitGuardTest.java new file mode 100644 index 0000000..3015531 --- /dev/null +++ b/backend/src/test/java/com/danyang/match/platform/photo/PhotoLimitGuardTest.java @@ -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 配置与规则纠偏)。 + * + *

历史缺陷:{@code AppController} 把「最多 6 张」写死、{@code PhotoService} 上传路径 + * 完全没有数量校验 —— 前端提示 6 张但后端不限,前后端规则漂移 + 并发可突破。本守卫用 + * mock 的 {@code lockActiveProfilePhotoIds}(FOR UPDATE 锁行数)+ 可配置 + * {@code ProfilePolicy} 验证:上限来自服务端配置、lifestyle 超限拒绝、avatar 为槽位替换、 + * 本地资格佐证不计入、拒绝发生在对象存储上传之前(不留孤儿对象)、默认值受控非无限。 + * + *

说明:真实并发突破由数据库 {@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 ids(int n) { + List 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()); + } +} diff --git a/backend/src/test/java/com/danyang/match/platform/photo/PhotoServiceTest.java b/backend/src/test/java/com/danyang/match/platform/photo/PhotoServiceTest.java index d2b1f1b..19ad6e1 100644 --- a/backend/src/test/java/com/danyang/match/platform/photo/PhotoServiceTest.java +++ b/backend/src/test/java/com/danyang/match/platform/photo/PhotoServiceTest.java @@ -7,7 +7,7 @@ import static org.junit.jupiter.api.Assertions.*; class PhotoServiceTest { - private final PhotoService photoService = new PhotoService(null, null, null); + private final PhotoService photoService = new PhotoService(null, null, null, null); @Test void testValidateMagicNumberJpeg() { diff --git a/backend/src/test/java/com/danyang/match/platform/photo/PhotoUploadValidationTest.java b/backend/src/test/java/com/danyang/match/platform/photo/PhotoUploadValidationTest.java index 8b2dc3e..a1b7749 100644 --- a/backend/src/test/java/com/danyang/match/platform/photo/PhotoUploadValidationTest.java +++ b/backend/src/test/java/com/danyang/match/platform/photo/PhotoUploadValidationTest.java @@ -6,6 +6,8 @@ import com.danyang.match.platform.photo.entity.UserPhoto; import com.danyang.match.platform.photo.mapper.UserPhotoMapper; import com.danyang.match.platform.photo.service.PhotoService; import com.danyang.match.platform.shared.config.FeatureFlagConfig; +import com.danyang.match.platform.shared.config.ProfileProperties; +import com.danyang.match.platform.shared.policy.DefaultProfilePolicy; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.mock.web.MockMultipartFile; @@ -36,7 +38,7 @@ class PhotoUploadValidationTest { }; private PhotoService newService(AmazonS3 s3, UserPhotoMapper mapper, FeatureFlagConfig flags) { - return new PhotoService(mapper, s3, flags); + return new PhotoService(mapper, s3, flags, new DefaultProfilePolicy(new ProfileProperties())); } private FeatureFlagConfig localEvidenceFlags(int maxMB, String allowedTypes) { diff --git a/backend/src/test/java/com/danyang/match/platform/shared/config/ConfigTest.java b/backend/src/test/java/com/danyang/match/platform/shared/config/ConfigTest.java index df45646..4ed900d 100644 --- a/backend/src/test/java/com/danyang/match/platform/shared/config/ConfigTest.java +++ b/backend/src/test/java/com/danyang/match/platform/shared/config/ConfigTest.java @@ -11,15 +11,6 @@ import org.springframework.test.context.ActiveProfiles; @ActiveProfiles("test") class ConfigTest { - @Test - void testMatchConfigDefaults() { - MatchConfig config = new MatchConfig(); - assertEquals(6, config.getDefaultDailyLimit()); - assertEquals(60, config.getDefaultScoreThreshold()); - assertEquals(180, config.getDefaultCooldownDays()); - assertEquals(1, config.getRuleVersion()); - } - @Test void testActivityConfigDefaults() { ActivityConfig config = new ActivityConfig(); diff --git a/backend/src/test/java/com/danyang/match/platform/verification/IdentityLivenessGuardTest.java b/backend/src/test/java/com/danyang/match/platform/verification/IdentityLivenessGuardTest.java new file mode 100644 index 0000000..48907f3 --- /dev/null +++ b/backend/src/test/java/com/danyang/match/platform/verification/IdentityLivenessGuardTest.java @@ -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 配置与规则纠偏)。 + * + *

验证「假开关」纠偏后开关真实驱动业务:默认关闭(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 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 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 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 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 st2 = service(mapper2, policy).getLivenessStatus("u1"); + assertEquals("failed", st2.get("livenessVerificationStatus")); + assertEquals(Boolean.FALSE, st2.get("enhancedVerified")); + } +} diff --git a/backend/src/test/resources/schema-h2.sql b/backend/src/test/resources/schema-h2.sql index cbe5b4e..5c3bba8 100644 --- a/backend/src/test/resources/schema-h2.sql +++ b/backend/src/test/resources/schema-h2.sql @@ -127,7 +127,7 @@ ALTER TABLE notifications ADD CONSTRAINT chk_notification_type CHECK ( CREATE TABLE recommendation_records ( id VARCHAR(36) PRIMARY KEY, user_id VARCHAR(36), target_user_id VARCHAR(36), source VARCHAR(32), score NUMERIC(5,2), level VARCHAR(16), reasons CLOB, shown_at TIMESTAMP WITH TIME ZONE, action VARCHAR(32), action_at TIMESTAMP WITH TIME ZONE, - rule_version INT, created_at TIMESTAMP WITH TIME ZONE, updated_at TIMESTAMP WITH TIME ZONE, deleted_at TIMESTAMP WITH TIME ZONE + rule_version INT, threshold_snapshot INT, created_at TIMESTAMP WITH TIME ZONE, updated_at TIMESTAMP WITH TIME ZONE, deleted_at TIMESTAMP WITH TIME ZONE ); CREATE TABLE activity_registrations ( id VARCHAR(36) PRIMARY KEY, activity_id VARCHAR(36), user_id VARCHAR(36), status VARCHAR(32), diff --git a/docs/PENDING_DECISIONS.md b/docs/PENDING_DECISIONS.md index 92c9808..4f15793 100644 --- a/docs/PENDING_DECISIONS.md +++ b/docs/PENDING_DECISIONS.md @@ -1,6 +1,11 @@ # 待确认决策项 — 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 控制) | ⏳ 待确认 | -| 人脸核验 Provider | 待定 | ⏳ 待确认 | +| 人脸核验是否V1.1首发强制 | 否(Feature Flag 控制,`identity.liveness.enabled` 默认 `false`) | ⏳ 待确认(CONFIG/TBD,**非 V1 强制**;本轮已接 `IdentityPolicy` 真实业务判定点,见 config-register / OQ-IDENTITY-001) | +| 人脸核验 Provider | 待定(`identity.liveness.provider` 默认 `none`) | ⏳ 待确认(选型未定,见 OQ-IDENTITY-001) | ### 本地资格 @@ -38,11 +43,11 @@ | 决策项 | 技术默认值 | 状态 | |--------|-----------|------| -| Banner最大活跃数量 | 待定 | ⏳ 待确认 | -| 推荐每日数量 | 6人 | ✅ 已完成 | -| 推荐阈值/最终权重 | 待定 | ⏳ 待确认 | -| Pass冷却时间 | 180天 | ✅ 已完成 | -| 照片上限 | 6张 | ✅ 已完成 | +| Banner最大活跃数量 | 待定(**真缺口**:代码无此限制,仅 seed 死值 5) | ⏳ 待确认(OQ-BANNER-001,需产品定值后实现 active-count 校验) | +| 推荐每日数量 | 6人 | ✅ 已完成(LOCKED,`match.default-daily-limit`) | +| 推荐阈值/最终权重 | **技术默认 60,未产品确认**(`matching.recommendation.minScore`,可配置 + V050 阈值快照) | ⏳ 待确认(OQ-MATCH-001;消解本文 :「阈值60分已确认」矛盾 —— 60 只是默认值) | +| Pass冷却时间 | 180天 | ✅ 已完成(LOCKED,`match.default-cooldown-days`) | +| 照片上限 | **技术默认 6,未产品确认**(`profile.photo.maxCount`,已配置化 + 服务器端强校验) | ⏳ 待确认(OQ-PROFILE-001;原标「✅ 已确认 6」有误,6 非产品正式确认值) | ### 数据统计 @@ -72,8 +77,8 @@ | 内容 | 婚恋课堂 + 婚恋反诈进入V1.1首发 | | 活动 | 多类型公益活动 + 活动双向沟通确认进入V1.1首发 | | 核心规则 | Required不可放宽 / Mutual后才Chat / Block优先 | -| 推荐 | 每日6人 / 阈值60分 / Pass冷却180天 | -| 资料 | 照片上限6张 / 兴趣标签上限8个 / 自我介绍500字符 | +| 推荐 | 每日6人(LOCKED)/ Pass冷却180天(LOCKED);**阈值60分仅为技术默认值,未产品确认**(OQ-MATCH-001) | +| 资料 | 兴趣标签上限8个 / 自我介绍500字符(LOCKED);**照片上限6张仅为技术默认值,未产品确认**(OQ-PROFILE-001) | | 隐私 | Privacy默认全TRUE | | 活动规则 | 需审核 / 男女名额独立配置 / 互选最大3人 / 截止后48小时 | | 关系状态 | 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)。* diff --git a/docs/STATUS.md b/docs/STATUS.md index 22c9e23..8f07ae8 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -1,10 +1,10 @@ # 项目状态跟踪 — 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 行)。 -最后更新: 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.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.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** | --- diff --git a/docs/governance/config-drift-audit.md b/docs/governance/config-drift-audit.md new file mode 100644 index 0000000..2a94e93 --- /dev/null +++ b/docs/governance/config-drift-audit.md @@ -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 剩余硬编码为已登记的未来项。* diff --git a/docs/governance/config-register.md b/docs/governance/config-register.md new file mode 100644 index 0000000..209712f --- /dev/null +++ b/docs/governance/config-register.md @@ -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 重复。* diff --git a/docs/governance/decision-register.md b/docs/governance/decision-register.md new file mode 100644 index 0000000..0dae926 --- /dev/null +++ b/docs/governance/decision-register.md @@ -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。* diff --git a/docs/governance/hardcoded-rule-audit.md b/docs/governance/hardcoded-rule-audit.md new file mode 100644 index 0000000..65df955 --- /dev/null +++ b/docs/governance/hardcoded-rule-audit.md @@ -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 按同一纪律统一(勿一次性扩大重构)。* diff --git a/docs/governance/open-questions.md b/docs/governance/open-questions.md new file mode 100644 index 0000000..101907e --- /dev/null +++ b/docs/governance/open-questions.md @@ -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 数量、报送口径、第三方开放遗留。* diff --git a/miniapp/constants/businessLimits.js b/miniapp/constants/businessLimits.js index 037613b..a9d20d0 100644 --- a/miniapp/constants/businessLimits.js +++ b/miniapp/constants/businessLimits.js @@ -46,11 +46,45 @@ exports.BUSINESS_LIMITS = { }; /** - * 用服务端返回的配置覆盖默认值(规范 §118) - * @param {Partial} overrides 服务端配置 - * @returns {typeof exports.BUSINESS_LIMITS} 合并后的配置 + * 服务端 /app/config 的嵌套结构 → 客户端 UPPER_SNAKE 常量的显式映射。 + * + *

此前用平铺合并 {@code {...BUSINESS_LIMITS, ...overrides}},命不中嵌套 key + * (服务端返回的是 {@code businessLimits.maxProfilePhotos} / {@code features.x}), + * 导致服务端下发的照片上限 / 推荐阈值等配置从不生效 —— 前后端规则漂移 + * (V1.0.1 配置纠偏 PROFILE-001 前端侧根因)。现改为按节(section)+ 键显式映射, + * 且仅在「服务端给了该键 && 客户端确有该常量」时才覆盖,缺省回退本地默认值。 */ -exports.mergeAppConfig = (overrides) => { - if (!overrides || typeof overrides !== 'object') return exports.BUSINESS_LIMITS; - return { ...exports.BUSINESS_LIMITS, ...overrides }; +const SERVER_SECTION_TO_CLIENT_KEY = { + businessLimits: { + 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; }; diff --git a/miniapp/pages/profile-photos/profile-photos.js b/miniapp/pages/profile-photos/profile-photos.js index e683dc0..19a1dba 100644 --- a/miniapp/pages/profile-photos/profile-photos.js +++ b/miniapp/pages/profile-photos/profile-photos.js @@ -33,9 +33,25 @@ Page({ onLoad() { this._initSystemInfo(); + this._applyPhotoLimit(); 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() { try { const sysInfo = wx.getSystemInfoSync(); @@ -70,7 +86,7 @@ Page({ onAddLifestyle() { const remaining = this.data.maxLifestyle - this.data.lifestylePhotos.length; if (remaining <= 0) { - appStore.toast(`最多上传 ${BUSINESS_LIMITS.MAX_PROFILE_PHOTOS} 张照片`); + appStore.toast(`最多上传 ${this.data.maxLifestyle + 1} 张照片`); return; } this._chooseAndUpload('lifestyle');