diff --git a/AGENTS.md b/AGENTS.md index 2d36070..0dd1d4f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -61,6 +61,7 @@ V1.1 是 V1.0 的增量升级。正确的 V1.0 核心按 KEEP 处理,不得因 24. **Service Station Operator 不可浏览全用户库** — 服务站人员只能处理现场预约和指定任务 25. **主办单位关系不自动授予数据权限** — Organization relationship ≠ 用户数据访问权限,权限由 Role+Permission+DataScope 控制 26. **前端 Capability 控制入口** — 用户端各功能入口可见性由服务端 CapabilityService 返回决定,前端不自行拼状态判断 +27. **配置与规则治理(V1.0.1 纠偏基线)** — 技术默认值 ≠ 产品正式确认规则。所有未经产品确认的数量/阈值/时间/比例/权重/容量/开关/冷却期,一律按「`DEFAULT + CONFIG`」管理:可配置化(`@ConfigurationProperties`/Feature Flag)、**不得**作为 `LOCKED` 业务规则写死;每个配置有唯一 canonical key,登记 `docs/governance/config-register.md`,未确认项登记 `docs/governance/open-questions.md`,已确认项才入 `docs/governance/decision-register.md`。涉及并发的上限必须有服务器端强校验 + 并发突破测试。详见 `docs/governance/`(config-register / open-questions / decision-register / hardcoded-rule-audit / config-drift-audit)。 --- 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 091ccf4..53acb25 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 @@ -18,16 +18,14 @@ public interface UserPhotoMapper extends BaseMapper { UserPhoto findAvatar(String userId); /** - * 锁定并统计该用户当前 active 的个人照片(头像 + 生活照,未软删)。 + * 统计该用户当前 active(未软删)的个人照片数(头像 + 生活照)。 * - *

用 {@code SELECT ... FOR UPDATE} 锁住这些行,使同一用户的并发上传被串行化 —— - * 先到的事务提交后,后到的事务在锁上等待、再重新读到最新数量(READ COMMITTED 下 - * FOR UPDATE 会做锁后复核),从而「并发上传不可突破上限」。 - * 参照 {@code ReviewTaskMapper} 的 {@code FOR UPDATE SKIP LOCKED} 先例(全代码库唯一 - * 悲观锁用法)。仅统计头像 + 生活照两类个人照片,本地资格佐证(local_eligibility) - * 另有独立上限,不计入。 + *

本地资格佐证(local_eligibility)各有独立上限(localEvidenceMaxFiles),不计入。 + * 并发安全由调用方保证:先 {@code UserMapper.lockUserRow(userId)}(FOR UPDATE 锁用户行, + * 串行化同一用户并发写、且 0 张照片时也有锁可加),锁后再执行本 COUNT(新语句、 + * READ COMMITTED 下读到最新已提交数据)。勿在无锁事务里单独使用本计数做上限判定。 */ - @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); + @Select("SELECT COUNT(*) FROM user_photos WHERE user_id = #{userId} AND deleted_at IS NULL " + + "AND photo_type IN ('avatar','lifestyle')") + long countActiveProfilePhotos(@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 db1564d..8edc173 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 @@ -40,6 +40,7 @@ public class PhotoService { private final AmazonS3 amazonS3; private final FeatureFlagConfig featureFlags; private final ProfilePolicy profilePolicy; + private final com.danyang.match.platform.user.mapper.UserMapper userMapper; @Value("${object-storage.bucket-private}") private String privateBucket; @@ -104,9 +105,15 @@ public class PhotoService { * 个人照片数量上限校验(服务器端强制,前端提示不可绕过)。 * *

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

    + *
  1. {@code UserMapper.lockUserRow}({@code SELECT ... FOR UPDATE})锁住恒存在的用户行 —— + * 串行化同一用户的并发写,且 **0 张照片时也有锁可加**(「首张并发」缺口);
  2. + *
  3. 锁之后再用新语句 {@code COUNT} active 照片(READ COMMITTED 下读到最新已提交数据)。 + * 若锁照片行再计数,H2 MVCC 快照可能漏读并发提交的新行(PHOTO-004 实测 3/3 全成功、 + * 突破上限的根因),故改为锁用户行。
  4. + *
* *

规则(PROFILE-001 / OQ-PROFILE-001):个人照片(头像 + 生活照)合计须 ≤ * {@code profile.photo.maxCount}(当前技术默认 6,未获产品正式确认,见 config-register)。 @@ -120,8 +127,11 @@ public class PhotoService { if (!"avatar".equals(photoType) && !"lifestyle".equals(photoType)) { return; // 非个人照片类型(佐证等)走各自上限,不在个人照片上限约束内 } + // 1) 锁用户行:串行化同用户并发写;用户不存在时返回 null,后续 insert 由 FK 兜底拒绝 + userMapper.lockUserRow(userId); + // 2) 锁后计数(新语句,READ COMMITTED 读最新已提交数据) + long active = photoMapper.countActiveProfilePhotos(userId); 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( diff --git a/backend/src/main/java/com/danyang/match/platform/shared/config/FeatureFlagConfig.java b/backend/src/main/java/com/danyang/match/platform/shared/config/FeatureFlagConfig.java index 7013c53..f6940af 100644 --- a/backend/src/main/java/com/danyang/match/platform/shared/config/FeatureFlagConfig.java +++ b/backend/src/main/java/com/danyang/match/platform/shared/config/FeatureFlagConfig.java @@ -33,6 +33,37 @@ public class FeatureFlagConfig { public static final String TYPE_INT = "int"; public static final String TYPE_STRING = "string"; + /** + * Canonical 配置键别名映射(V1.0.1 配置与规则纠偏 CFG-001)。 + * + *

key = 配置中心的正式/遗留键,value = Java 字段名(实现键)。 + * 活体核验开关的唯一正式配置键是 {@code identity.liveness.enabled} + * (见 docs/governance/config-register.md,V051 起 feature_flag_overrides 只写该键); + * {@code livenessVerification.enabled} 是 V051 之前的 seed 遗留键,仅作读兼容别名。 + * Java 内部字段保持 {@code livenessEnabled}(文档允许:DTO/Policy 内部变量可如此命名)。 + */ + public static final Map KEY_ALIASES = Map.of( + "identity.liveness.enabled", "livenessEnabled", + "livenessVerification.enabled", "livenessEnabled"); + + /** + * 实现字段名 → canonical 配置键(写库/写 seed 时用;避免同一业务含义落两套 key)。 + * 非别名 flag 原样返回(其字段名即配置键)。 + */ + public String canonicalKey(String fieldName) { + if ("livenessEnabled".equals(fieldName)) { + return "identity.liveness.enabled"; + } + return fieldName; + } + + /** + * 把配置中心键(canonical 或遗留别名)解析为实现字段名;未知键原样返回(由调用方判未知)。 + */ + public String resolveKey(String flagName) { + return KEY_ALIASES.getOrDefault(flagName, flagName); + } + // ---- V1.0 已有 Flag ---- private boolean activityEnabled = true; private boolean matchmakerEnabled = true; @@ -117,6 +148,9 @@ public class FeatureFlagConfig { * 修法明确:删掉那一行(DELETE /operator/feature-flags/{flagName})。 */ private void applyPersistedValue(String flagName, String rawValue) { + // CFG-001:数据库行可能是 canonical 键(identity.liveness.enabled)或遗留别名 + // (livenessVerification.enabled),先解析到实现字段名再校验/落值。 + flagName = resolveKey(flagName); if (!getAllFlags().containsKey(flagName)) { log.warn("忽略数据库中已不存在的 flag 覆盖: {}(可由 DELETE /operator/feature-flags/{} 清理)", flagName, flagName); @@ -162,6 +196,7 @@ public class FeatureFlagConfig { * @throws NoSuchFieldException flag 不存在 */ public void setFlagValue(String flagName, Object value) { + flagName = resolveKey(flagName); // CFG-001:canonical/遗留键都先解析到实现字段名 try { Field field = FeatureFlagConfig.class.getDeclaredField(flagName); Class type = field.getType(); @@ -197,7 +232,7 @@ public class FeatureFlagConfig { */ public String valueTypeOf(String flagName) { try { - Class type = FeatureFlagConfig.class.getDeclaredField(flagName).getType(); + Class type = FeatureFlagConfig.class.getDeclaredField(resolveKey(flagName)).getType(); if (type == boolean.class || type == Boolean.class) { return TYPE_BOOLEAN; } @@ -212,6 +247,7 @@ public class FeatureFlagConfig { /** 恢复为默认配置(删除覆盖后用)。 */ public void resetToDefault(String flagName) { + flagName = resolveKey(flagName); Object def = defaultsSnapshot.get(flagName); if (def == null) { log.warn("默认配置快照为空,无法恢复 {}: reloadFromPersistence() 尚未执行", flagName); diff --git a/backend/src/main/java/com/danyang/match/platform/shared/controller/FeatureFlagController.java b/backend/src/main/java/com/danyang/match/platform/shared/controller/FeatureFlagController.java index 14b9ff4..c9e37cc 100644 --- a/backend/src/main/java/com/danyang/match/platform/shared/controller/FeatureFlagController.java +++ b/backend/src/main/java/com/danyang/match/platform/shared/controller/FeatureFlagController.java @@ -52,7 +52,10 @@ public class FeatureFlagController { @AuthenticationPrincipal OperatorPrincipal principal, @PathVariable String flagName, @RequestBody Map body) { - if (!featureFlagConfig.getAllFlags().containsKey(flagName)) { + // CFG-001:入参可能是 canonical 键(identity.liveness.enabled)或遗留别名 + // (livenessVerification.enabled),先解析到实现字段名再校验/落值。 + String field = featureFlagConfig.resolveKey(flagName); + if (!featureFlagConfig.getAllFlags().containsKey(field)) { return ApiResponse.error( ApiResponseCode.BAD_REQUEST, "未知的 Feature Flag: " + flagName); @@ -70,11 +73,11 @@ public class FeatureFlagController { "value 不能为 null;要恢复默认请改用 DELETE"); } try { - featureFlagConfig.setFlagValue(flagName, value); + featureFlagConfig.setFlagValue(field, value); } catch (ClassCastException e) { return ApiResponse.error( ApiResponseCode.BAD_REQUEST, - "value 类型与 Flag " + flagName + " 不匹配: " + e.getMessage()); + "value 类型与 Flag " + field + " 不匹配: " + e.getMessage()); } catch (IllegalArgumentException e) { return ApiResponse.error( ApiResponseCode.BAD_REQUEST, @@ -84,16 +87,19 @@ public class FeatureFlagController { ApiResponseCode.BAD_REQUEST, "无法更新该 Flag: " + e.getMessage()); } - // 内存已生效,落库使其成为持久事实(重启后仍生效) + // 内存已生效,落库使其成为持久事实(重启后仍生效)。 + // 落库键用 canonical 名(identity.liveness.enabled),禁止再写入 livenessEnabled 等旧键。 String operatorId = principal == null ? null : principal.id(); + String canonicalKey = featureFlagConfig.canonicalKey(field); featureFlagOverrideService.save( - flagName, - String.valueOf(featureFlagConfig.getAllFlags().get(flagName)), - featureFlagConfig.valueTypeOf(flagName), + canonicalKey, + String.valueOf(featureFlagConfig.getAllFlags().get(field)), + featureFlagConfig.valueTypeOf(field), operatorId); return ApiResponse.ok(Map.of( "flagName", flagName, - "value", featureFlagConfig.getAllFlags().get(flagName), + "persistedKey", canonicalKey, + "value", featureFlagConfig.getAllFlags().get(field), "updated", true, "persisted", true)); } @@ -106,16 +112,18 @@ public class FeatureFlagController { */ @DeleteMapping("/{flagName}") public ApiResponse> resetFlag(@PathVariable String flagName) { - if (!featureFlagConfig.getAllFlags().containsKey(flagName)) { + String field = featureFlagConfig.resolveKey(flagName); + if (!featureFlagConfig.getAllFlags().containsKey(field)) { return ApiResponse.error( ApiResponseCode.BAD_REQUEST, "未知的 Feature Flag: " + flagName); } - featureFlagOverrideService.delete(flagName); - featureFlagConfig.resetToDefault(flagName); + // 覆盖行存的是 canonical 键(CFG-001),删除按 canonical 键 + featureFlagOverrideService.delete(featureFlagConfig.canonicalKey(field)); + featureFlagConfig.resetToDefault(field); return ApiResponse.ok(Map.of( "flagName", flagName, - "value", featureFlagConfig.getAllFlags().get(flagName), + "value", featureFlagConfig.getAllFlags().get(field), "reset", true)); } } diff --git a/backend/src/main/java/com/danyang/match/platform/user/mapper/UserMapper.java b/backend/src/main/java/com/danyang/match/platform/user/mapper/UserMapper.java index 8fa7877..7d6036d 100644 --- a/backend/src/main/java/com/danyang/match/platform/user/mapper/UserMapper.java +++ b/backend/src/main/java/com/danyang/match/platform/user/mapper/UserMapper.java @@ -22,4 +22,17 @@ public interface UserMapper extends BaseMapper { @Select("SELECT COUNT(*) FROM users WHERE id_number_hash = #{hash} AND verification_status = 'verified' AND deleted_at IS NULL") int countByIdNumberHash(@Param("hash") String hash); + + /** + * 锁住用户行({@code SELECT ... FOR UPDATE}),用于把同一用户的并发写串行化。 + * + *

用法:在事务内先锁用户行、再用新语句读派生计数(如 active 照片数), + * 读到的是锁释放后的最新已提交数据(READ COMMITTED)。相比「锁照片行再计数」, + * 用户行恒存在 —— 0 张照片时也有锁可加,堵住「首张并发」突破上限的缺口 + * (PHOTO-004,纠偏方案 §四并发上传要求)。参照 {@code ReviewTaskMapper} 的 FOR UPDATE 先例。 + * + * @return 用户 id;用户不存在/已软删则返回 null(后续 insert 由 user_photos.user_id FK 兜底拒绝) + */ + @Select("SELECT id FROM users WHERE id = #{userId} FOR UPDATE") + String lockUserRow(@Param("userId") String userId); } diff --git a/backend/src/main/resources/db/migration/V051__canonical_liveness_flag_key.sql b/backend/src/main/resources/db/migration/V051__canonical_liveness_flag_key.sql new file mode 100644 index 0000000..6f38613 --- /dev/null +++ b/backend/src/main/resources/db/migration/V051__canonical_liveness_flag_key.sql @@ -0,0 +1,24 @@ +-- V051: 活体核验配置键归一(CFG-001,V1.0.1 配置与规则纠偏) +-- +-- 背景:活体开关此前存在「同一业务含义多套 key」的漂移 —— +-- * seed(demo-seed-final.sql)写过 livenessVerification.enabled(system_configs,死表); +-- * FeatureFlagConfig 实现键为 livenessEnabled(feature_flag_overrides 可用键); +-- * 治理文档 canonical 名为 identity.liveness.enabled。 +-- 本迁移把 feature_flag_overrides 收敛为「只写 canonical 键」: +-- 1) 删除遗留键(livenessVerification.enabled / livenessEnabled)的覆盖行; +-- 2) 写入 canonical 键 identity.liveness.enabled = false(默认关闭,fail-safe, +-- 与 Java 字段默认值一致;运行期开启仍走 PUT /operator/feature-flags/{flagName}, +-- 其落库键已由 FeatureFlagController 归一为 canonical 名)。 +-- +-- 说明:identity.liveness.enabled 与实现字段 livenessEnabled 的映射由 +-- FeatureFlagConfig.KEY_ALIASES 承担(读兼容),文档见 docs/governance/config-register.md。 +-- 语法刻意避免 PG 专有 ON CONFLICT(H2 兼容约定,同 V048 取舍)。 + +DELETE FROM feature_flag_overrides +WHERE flag_key IN ('livenessVerification.enabled', 'livenessEnabled'); + +INSERT INTO feature_flag_overrides (flag_key, flag_value, value_type, updated_by, updated_at) +SELECT 'identity.liveness.enabled', 'false', 'boolean', NULL, NOW() +WHERE NOT EXISTS ( + SELECT 1 FROM feature_flag_overrides WHERE flag_key = 'identity.liveness.enabled' +); 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 97cc5e4..1fc6919 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 @@ -29,7 +29,10 @@ class MigrationValidationTest { // V049 users.closed_at(保留期起算点,此前注销时间只存在于日志)+ account.retention.purge 权限 // V1.0.1 配置与规则纠偏(MATCH-001):V050 recommendation_records.threshold_snapshot // —— 推荐阈值 60 为技术默认(未产品确认),记录当次阈值快照 + rule_version 以支持历史追溯 - private static final int LATEST_MIGRATION = 50; + // V1.0.1 配置与规则纠偏(CFG-001):V051 活体核验配置键归一 + // —— feature_flag_overrides 清理遗留键(livenessVerification.enabled / livenessEnabled), + // 写入 canonical 键 identity.liveness.enabled=false(fail-safe 默认关闭) + private static final int LATEST_MIGRATION = 51; private static final String MIGRATION_DIR = "backend/src/main/resources/db/migration/"; diff --git a/backend/src/test/java/com/danyang/match/platform/photo/PhotoLimitConcurrencyGuardTest.java b/backend/src/test/java/com/danyang/match/platform/photo/PhotoLimitConcurrencyGuardTest.java new file mode 100644 index 0000000..125dd4b --- /dev/null +++ b/backend/src/test/java/com/danyang/match/platform/photo/PhotoLimitConcurrencyGuardTest.java @@ -0,0 +1,113 @@ +package com.danyang.match.platform.photo; + +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.constant.Constants; +import com.danyang.match.platform.shared.config.ProfileProperties; +import com.danyang.match.platform.user.entity.User; +import com.danyang.match.platform.user.mapper.UserMapper; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.*; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * 照片上限真实并发突破守卫(PHOTO-004 / 纠偏方案约束 6:「涉及并发的上限必须测试并发突破」)。 + * + *

此前 {@link PhotoLimitGuardTest} 用 mock 断言了「计数-拒绝」判定逻辑与「拒绝先于 S3」的 + * 顺序,但并发保证本身({@code SELECT ... FOR UPDATE} 串行化同用户并发写)未在真实 DB 上验证。 + * 本测试起真实 H2(test profile)+ 真实 {@link PhotoService}(FOR UPDATE 锁),多线程同时冲顶, + * 钉住「最终 active 数量恒 ≤ 上限」这一不变量 —— 这是「并发上传不可突破上限」的可证事实。 + * + *

注意:本测试不加 {@code @Transactional}(线程间必须互相看见对方的提交, + * 事务回滚会掩盖真实并发行为)。用随机 userId 隔离、{@link AfterEach} 清理,避免污染共享 H2。 + */ +@SpringBootTest +@ActiveProfiles("test") +class PhotoLimitConcurrencyGuardTest { + + @Autowired private PhotoService photoService; + @Autowired private UserPhotoMapper userPhotoMapper; + @Autowired private UserMapper userMapper; + @Autowired private ProfileProperties profileProperties; + + private final String userId = UUID.randomUUID().toString(); + + @AfterEach + void cleanup() { + userPhotoMapper.delete(new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper() + .eq(UserPhoto::getUserId, userId)); + userMapper.deleteById(userId); + } + + private int countActiveProfilePhotos(String uid) { + return (int) userPhotoMapper.countActiveProfilePhotos(uid); + } + + private void seedUserAndPhotos(int activeCount) { + User user = new User(); + user.setId(userId); + user.setWechatOpenid("openid-" + userId); + user.setPhone("138" + userId.substring(0, 8)); + user.setAccountStatus(Constants.ACCOUNT_STATUS_NORMAL); + userMapper.insert(user); + for (int i = 0; i < activeCount; i++) { + UserPhoto p = new UserPhoto(); + p.setUserId(userId); + p.setFileKey("seed/" + userId + "/" + i + ".jpg"); + p.setPhotoType("lifestyle"); + p.setSortOrder(i); + p.setReviewStatus(Constants.REVIEW_STATUS_PENDING); + userPhotoMapper.insert(p); + } + } + + @Test + @DisplayName("PHOTO-004:3 线程并发冲顶,最终 active 数量不得突破上限(FOR UPDATE 串行化)") + void concurrentUploadsCannotExceedCap() throws Exception { + int cap = profileProperties.getPhoto().getMaxCount(); // 6 + // 种到「离上限还差 1 张」:cap-1 张 active 生活照 + seedUserAndPhotos(cap - 1); + + int threads = 3; + ExecutorService pool = Executors.newFixedThreadPool(threads); + CyclicBarrier barrier = new CyclicBarrier(threads); + List> futures = new ArrayList<>(); + for (int i = 0; i < threads; i++) { + final int idx = i; + futures.add(pool.submit(() -> { + barrier.await(); // 三线并发启动,真实争抢锁 + try { + photoService.savePhoto(userId, "concurrent/" + idx + ".jpg", "lifestyle", idx); + return true; + } catch (RuntimeException e) { + return false; // 超限被服务器端拒绝 + } + })); + } + pool.shutdown(); + assertTrue(pool.awaitTermination(60, java.util.concurrent.TimeUnit.SECONDS), "并发线程未在 60s 内结束(疑似死锁)"); + + List results = new ArrayList<>(); + for (Future f : futures) { + results.add(f.get()); + } + int active = countActiveProfilePhotos(userId); + + // 3 线程争抢「第 cap 张」这一个空位:恰好 1 个成功(补到 cap),其余 2 个超限被拒 + assertEquals(1, results.stream().filter(Boolean::booleanValue).count(), + "只有 1 张能补到上限;其余并发上传必须被拒绝,实际成功 " + results.stream().filter(Boolean::booleanValue).count()); + assertEquals(cap, active, + "最终 active 数量必须恒等于上限、不得突破,实际 " + active); + } +} 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 index 3015531..a7e7ef6 100644 --- a/backend/src/test/java/com/danyang/match/platform/photo/PhotoLimitGuardTest.java +++ b/backend/src/test/java/com/danyang/match/platform/photo/PhotoLimitGuardTest.java @@ -10,13 +10,12 @@ 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 com.danyang.match.platform.user.mapper.UserMapper; 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; @@ -28,12 +27,11 @@ import static org.mockito.Mockito.*; * *

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

说明:真实并发突破由数据库 {@code FOR UPDATE} 串行化保证(见 UserPhotoMapper 注释), - * 单测断言的是「计数-拒绝」判定逻辑与「拒绝先于 S3 上传」的顺序,二者共同覆盖 PHOTO-001~007。 + *

真实多线程并发突破见 {@link PhotoLimitConcurrencyGuardTest}(H2 + FOR UPDATE 实测)。 */ class PhotoLimitGuardTest { @@ -42,32 +40,30 @@ class PhotoLimitGuardTest { }; /** 构造一个「active 个人照片数 = activeCount、配置上限 = maxCount」的 PhotoService。 */ - private PhotoService service(int activeCount, int maxCount, - AmazonS3 s3, UserPhotoMapper mapper) { - when(mapper.lockActiveProfilePhotoIds(anyString())).thenReturn(ids(activeCount)); + private PhotoService service(int activeCount, int maxCount, AmazonS3 s3, UserPhotoMapper mapper, UserMapper userMapper) { + when(userMapper.lockUserRow(anyString())).thenReturn("u1"); // 用户行锁成功 + when(mapper.countActiveProfilePhotos(anyString())).thenReturn((long) 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); + return new PhotoService(mapper, s3, mock(FeatureFlagConfig.class), policy, userMapper); } - private static List ids(int n) { - List list = new ArrayList<>(); - for (int i = 0; i < n; i++) { - list.add("id-" + i); - } - return list; + private UserMapper mockUserMapper() { + return mock(UserMapper.class); } @Test - @DisplayName("PHOTO-001:上限6、已有5张 → 第6张 lifestyle 通过") + @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); + UserMapper userMapper = mockUserMapper(); + PhotoService svc = service(5, 6, s3, mapper, userMapper); assertDoesNotThrow(() -> svc.ensureWithinPhotoLimit("u1", "lifestyle")); - verify(mapper).lockActiveProfilePhotoIds("u1"); // 确实走了 FOR UPDATE 锁计数 + verify(userMapper).lockUserRow("u1"); // 锁用户行(串行化同用户并发写) + verify(mapper).countActiveProfilePhotos("u1"); // 锁后计数 } @Test @@ -75,7 +71,7 @@ class PhotoLimitGuardTest { void lifestyleAtLimitRejected() { AmazonS3 s3 = mock(AmazonS3.class); UserPhotoMapper mapper = mock(UserPhotoMapper.class); - PhotoService svc = service(6, 6, s3, mapper); + PhotoService svc = service(6, 6, s3, mapper, mockUserMapper()); BusinessException ex = assertThrows(BusinessException.class, () -> svc.ensureWithinPhotoLimit("u1", "lifestyle")); @@ -87,7 +83,7 @@ class PhotoLimitGuardTest { void raisingMaxCountViaConfigAllowsMore() { AmazonS3 s3 = mock(AmazonS3.class); UserPhotoMapper mapper = mock(UserPhotoMapper.class); - PhotoService svc = service(6, 8, s3, mapper); + PhotoService svc = service(6, 8, s3, mapper, mockUserMapper()); assertDoesNotThrow(() -> svc.ensureWithinPhotoLimit("u1", "lifestyle")); } @@ -98,35 +94,39 @@ class PhotoLimitGuardTest { AmazonS3 s3 = mock(AmazonS3.class); UserPhotoMapper mapper = mock(UserPhotoMapper.class); // 已有 1 avatar + 5 lifestyle = 6,替换头像总量不变 - PhotoService svc = service(6, 6, s3, mapper); + PhotoService svc = service(6, 6, s3, mapper, mockUserMapper()); assertDoesNotThrow(() -> svc.ensureWithinPhotoLimit("u1", "avatar")); } @Test - @DisplayName("已删除/失效照片不计入(mapper 只返回 active 行,此处以 0 active 验证)") + @DisplayName("PHOTO-005:已删除/失效照片不计入(mapper 只返回 active 数,此处以 0 active 验证)") void deletedPhotosNotCounted() { AmazonS3 s3 = mock(AmazonS3.class); UserPhotoMapper mapper = mock(UserPhotoMapper.class); - when(mapper.lockActiveProfilePhotoIds(anyString())).thenReturn(ids(0)); + when(mapper.countActiveProfilePhotos(anyString())).thenReturn(0L); + UserMapper userMapper = mockUserMapper(); + when(userMapper.lockUserRow(anyString())).thenReturn("u1"); ProfilePolicy policy = mock(ProfilePolicy.class); when(policy.getMaxPhotoCount()).thenReturn(6); - PhotoService svc = new PhotoService(mapper, s3, mock(FeatureFlagConfig.class), policy); + PhotoService svc = new PhotoService(mapper, s3, mock(FeatureFlagConfig.class), policy, userMapper); // 即便历史上传过 10 张(已软删),active=0 → 可再传 assertDoesNotThrow(() -> svc.ensureWithinPhotoLimit("u1", "lifestyle")); } @Test - @DisplayName("PHOTO-006:本地资格佐证不计入个人照片上限(独立上限,直接跳过)") + @DisplayName("PHOTO-006:本地资格佐证不计入个人照片上限(独立上限,直接跳过、不加锁)") void localEligibilityExcludedFromProfileLimit() { AmazonS3 s3 = mock(AmazonS3.class); UserPhotoMapper mapper = mock(UserPhotoMapper.class); + UserMapper userMapper = mockUserMapper(); // 即使个人照片已超上限,佐证类型也不应被个人照片上限拦截 - PhotoService svc = service(99, 6, s3, mapper); + PhotoService svc = service(99, 6, s3, mapper, userMapper); assertDoesNotThrow(() -> svc.ensureWithinPhotoLimit("u1", "local_eligibility")); - verify(mapper, never()).lockActiveProfilePhotoIds(anyString()); + verify(userMapper, never()).lockUserRow(anyString()); + verify(mapper, never()).countActiveProfilePhotos(anyString()); } @Test @@ -134,7 +134,7 @@ class PhotoLimitGuardTest { void uploadRejectedBeforeS3WhenOverLimit() { AmazonS3 s3 = mock(AmazonS3.class); UserPhotoMapper mapper = mock(UserPhotoMapper.class); - PhotoService svc = service(6, 6, s3, mapper); + PhotoService svc = service(6, 6, s3, mapper, mockUserMapper()); MockMultipartFile file = new MockMultipartFile("file", "a.jpg", "image/jpeg", JPEG_BYTES); assertThrows(BusinessException.class, @@ -143,7 +143,7 @@ class PhotoLimitGuardTest { } @Test - @DisplayName("PHOTO-007b:前端伪造 maxCount 无效——后端只认服务端配置(此默认 6 受控非无限)") + @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 19ad6e1..99df407 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, null); + private final PhotoService photoService = new PhotoService(null, 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 a1b7749..4e59dda 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 @@ -38,7 +38,8 @@ class PhotoUploadValidationTest { }; private PhotoService newService(AmazonS3 s3, UserPhotoMapper mapper, FeatureFlagConfig flags) { - return new PhotoService(mapper, s3, flags, new DefaultProfilePolicy(new ProfileProperties())); + return new PhotoService(mapper, s3, flags, new DefaultProfilePolicy(new ProfileProperties()), + org.mockito.Mockito.mock(com.danyang.match.platform.user.mapper.UserMapper.class)); } private FeatureFlagConfig localEvidenceFlags(int maxMB, String allowedTypes) { diff --git a/backend/src/test/java/com/danyang/match/platform/shared/controller/FeatureFlagControllerTest.java b/backend/src/test/java/com/danyang/match/platform/shared/controller/FeatureFlagControllerTest.java index 01cb0e2..248f035 100644 --- a/backend/src/test/java/com/danyang/match/platform/shared/controller/FeatureFlagControllerTest.java +++ b/backend/src/test/java/com/danyang/match/platform/shared/controller/FeatureFlagControllerTest.java @@ -124,4 +124,35 @@ class FeatureFlagControllerTest { assertFalse(resp.isSuccess()); assertTrue(fixture.isReferralEnabled(), "类型不匹配不应静默把功能置为关闭"); } + + // ---- CFG-001:canonical 键 identity.liveness.enabled(V051 起为唯一正式配置键)---- + + @Test + void updateCanonicalLivenessKeyIsAcceptedAndApplied() { + ApiResponse> resp = + controller.updateFlag(principal, "identity.liveness.enabled", Map.of("value", true)); + assertTrue(resp.isSuccess(), "canonical 键 identity.liveness.enabled 必须可经 API 更新"); + assertTrue(fixture.isLivenessEnabled(), "canonical 键更新应解析到 livenessEnabled 字段并生效"); + assertEquals("identity.liveness.enabled", resp.getData().get("persistedKey"), + "落库键应为 canonical 名"); + } + + @Test + void updateLegacyLivenessAliasIsAccepted() { + ApiResponse> resp = + controller.updateFlag(principal, "livenessVerification.enabled", Map.of("value", true)); + assertTrue(resp.isSuccess(), "遗留别名键保持读/写兼容(过渡期)"); + assertTrue(fixture.isLivenessEnabled()); + assertEquals("identity.liveness.enabled", resp.getData().get("persistedKey"), + "即使经别名更新,落库也归一到 canonical 键"); + } + + @Test + void resetCanonicalLivenessKeyRestoresDefault() { + controller.updateFlag(principal, "identity.liveness.enabled", Map.of("value", true)); + assertTrue(fixture.isLivenessEnabled()); + ApiResponse> resp = controller.resetFlag("identity.liveness.enabled"); + assertTrue(resp.isSuccess()); + assertFalse(fixture.isLivenessEnabled(), "DELETE 后应回到默认 false(fail-safe)"); + } } diff --git a/backend/src/test/java/com/danyang/match/platform/shared/controller/FeatureFlagPersistenceGuardTest.java b/backend/src/test/java/com/danyang/match/platform/shared/controller/FeatureFlagPersistenceGuardTest.java index 9f000da..dc9dfa1 100644 --- a/backend/src/test/java/com/danyang/match/platform/shared/controller/FeatureFlagPersistenceGuardTest.java +++ b/backend/src/test/java/com/danyang/match/platform/shared/controller/FeatureFlagPersistenceGuardTest.java @@ -225,4 +225,54 @@ class FeatureFlagPersistenceGuardTest { "覆盖值无法解析时应拒绝加载,而不是静默回退默认值"); assertTrue(ex.getMessage().contains("localEvidenceMaxFiles"), ex.getMessage()); } + + // ------------------------------------------------------------------ + // CFG-001:canonical 键 identity.liveness.enabled(V051)+ 遗留别名读兼容 + // ------------------------------------------------------------------ + + /** V051 后 DB 行键为 canonical 名:加载必须解析到 livenessEnabled 字段并生效(不得静默忽略)。 */ + @Test + void canonicalLivenessKeyIsLoadedAndApplied() { + overrideService.save("identity.liveness.enabled", "true", "boolean", OPERATOR_ID); + FeatureFlagConfig cfg = bootConfig(); + assertTrue(cfg.isLivenessEnabled(), + "canonical 键的覆盖行必须被加载并生效 —— 此前点分键会被 reloadFromPersistence 静默忽略(假开关根因)"); + } + + /** V051 清理前的遗留键读兼容(过渡期保护;V051 后生产不会再出现此类行)。 */ + @Test + void legacyLivenessKeyStillLoadsForCompatibility() { + overrideService.save("livenessVerification.enabled", "true", "boolean", OPERATOR_ID); + FeatureFlagConfig cfg = bootConfig(); + assertTrue(cfg.isLivenessEnabled(), "遗留别名键须保持读兼容"); + } + + /** PUT 落库必须归一到 canonical 键,禁止再写 livenessEnabled 旧键。 */ + @Test + void putPersistsUnderCanonicalKeyNotLegacyFieldKey() { + FeatureFlagConfig cfg = bootConfig(); + ApiResponse> resp = + controllerFor(cfg).updateFlag(principal, "livenessEnabled", Map.of("value", true)); + assertTrue(resp.isSuccess()); + assertNotNull(overrideMapper.findByKey("identity.liveness.enabled"), + "PUT 的落库键应为 canonical 名 identity.liveness.enabled"); + assertNull(overrideMapper.findByKey("livenessEnabled"), + "不得再以旧字段名 livenessEnabled 写覆盖行(双键漂移)"); + assertTrue(bootConfig().isLivenessEnabled(), "重启等价路径后仍应为 true"); + } + + /** FF-LIVENESS-003:开关切换后新加载立即反映新值,不存在旧缓存长期滞留。 */ + @Test + void ffLiveness003_toggleReflectsOnNextLoadNoStaleValue() { + FeatureFlagConfig cfg = bootConfig(); + assertFalse(cfg.isLivenessEnabled(), "前置:默认 false"); + + controllerFor(cfg).updateFlag(principal, "livenessEnabled", Map.of("value", true)); + assertTrue(bootConfig().isLivenessEnabled(), "开启后的新实例必须读到 true"); + + controllerFor(cfg).resetFlag("livenessEnabled"); + assertFalse(bootConfig().isLivenessEnabled(), + "恢复默认后新实例必须回到 false(fail-safe),旧值不得滞留"); + assertNull(overrideMapper.findByKey("identity.liveness.enabled"), "reset 应删除 canonical 键的覆盖行"); + } } diff --git a/docs/STATUS.md b/docs/STATUS.md index 8f07ae8..3bc1455 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`。** **V1.0.1 配置与规则纠偏已完成**(照片上限配置化 + 服务器端强校验 / 活体开关闭环 / 推荐阈值纠偏 + 规则版本化快照 / governance 三表 + 漂移审计):全量回归 **733 / 0 / 0 / 3**(基线 717 + 17 新增 − 1 删死测试),UI 契约门禁 **18/18**。 +> **2026-09-21 当前有效结论:`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 五表 + 漂移审计):**canonical key 字面化(V051 收敛 `identity.liveness.enabled` 为 `feature_flag_overrides` 唯一正式键 + `KEY_ALIASES` 读兼容遗留键)**、**PHOTO-004 真实并发突破实测**(H2 MVCC 快照漏读缺陷被新测试咬住 → 并发控制改为「锁用户行 + 锁后计数」,3 线程并发冲顶恰 1 成功不突破上限)、AGENTS.md 规则 27(配置治理纪律)+ `docs/governance/known-risks.md`(7 项已知风险含本机 WSL/Docker 故障)。全量回归 **741 / 0 / 0 / 3**(基线 717 + 25 新增 − 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`(V1.0.1 配置与规则纠偏落地,回归 733;`feat-ui-v11` 保留为冗余锚点) +最后更新: 2026-09-21 — 分支 `main`(V1.0.1 配置与规则纠偏全项落地:V050 阈值快照 / V051 活体键归一 / 并发强校验改锁用户行 / AGENTS 规则 27 / governance 六表;回归 741;`feat-ui-v11` 保留为冗余锚点) ## 当前唯一有效状态 @@ -62,7 +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** | +| 1.1.0-rc.21 | 2026-09-21 | **V1.0.1 配置与规则纠偏**(根治「技术默认值被误当产品规则 / 配置存在但未闭环 / 文档代码漂移」):① **照片上限配置化** `profile.photo.maxCount`(默认 6 未产品确认)+ `ProfilePolicy` + 服务器端并发强校验(新增 `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 / known-risks)并消解 PENDING 阈值/照片矛盾;⑤ 守卫 `PhotoLimitGuardTest` 8 + `IdentityLivenessGuardTest` 5 + `RecommendationThresholdSnapshotGuardTest` 4(均经负向注入可红);⑥ **canonical key 字面化**:`FeatureFlagConfig.KEY_ALIASES`(`identity.liveness.enabled` / 遗留 `livenessVerification.enabled` → 实现字段 `livenessEnabled` 读兼容)+ Controller PUT/DELETE 落库归一 canonical 键 + **V051** 清理遗留键并写入 canonical 行;⑦ **PHOTO-004 真实并发突破实测** `PhotoLimitConcurrencyGuardTest`(3 线程 H2 并发冲顶)——实测咬住「锁照片行+读时计数」在 H2 MVCC 下快照漏读、3/3 全成功突破上限的缺陷,并发控制改为**锁用户行(`UserMapper.lockUserRow` FOR UPDATE)+ 锁后新语句计数**(0 张照片亦有锁可加,补齐「首张并发」缺口),改后恰 1 成功;⑧ `AGENTS.md` 规则 27(配置与规则治理纪律:技术默认值≠产品确认规则 + canonical key 唯一 + 并发上限必须服务器强校验+并发突破测试)。**全量回归 741 / 0 / 0 / 3 全绿(基线 717 + 25 新增 − 1 删死测试);UI 契约门禁 18/18** | --- diff --git a/docs/governance/config-register.md b/docs/governance/config-register.md index 209712f..85b19d0 100644 --- a/docs/governance/config-register.md +++ b/docs/governance/config-register.md @@ -27,7 +27,7 @@ | 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.enabled` | **DB 键即 canonical 名**(`feature_flag_overrides.flag_key = 'identity.liveness.enabled'`,V051 写入;Java 字段 `livenessEnabled`,yml `features.liveness-enabled`) | CONFIG / TBD | `false` | 否 | 是 | `FeatureFlagConfig.KEY_ALIASES` 承担「canonical 键 ↔ 实现字段」映射(`identity.liveness.enabled` / 遗留 `livenessVerification.enabled` → `livenessEnabled`);写库归一(Controller PUT/DELETE 落 canonical 键);`IdentityPolicy` + `GET /verification/liveness-status` 真实判定点(FF-LIVENESS-002)。**活体不是 V1 强制规则**;默认关闭不阻断实名主流程;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` 支持追溯。 | @@ -46,10 +46,11 @@ | 漂移 | 处置 | |------|------| -| seed `livenessVerification.enabled` vs 实现键 `livenessEnabled`(两套 key 同义) | **已删** `demo-seed-final.sql` 的死 seed 行;canonical 名 `identity.liveness.enabled` 映射到唯一实现键 `livenessEnabled`(见上表)。 | +| seed `livenessVerification.enabled` vs 实现键 `livenessEnabled` vs 治理名 `identity.liveness.enabled`(三套 key 同义) | **已删** `demo-seed-final.sql` 的死 seed 行;**V051 迁移**把 `feature_flag_overrides` 收敛为只写 canonical 键 `identity.liveness.enabled`(删除遗留 `livenessVerification.enabled`/`livenessEnabled` 行 + 写入 canonical 行);`FeatureFlagConfig.KEY_ALIASES` 承担「canonical/遗留键 → 实现字段 `livenessEnabled`」读兼容映射,Controller PUT/DELETE 落库归一到 canonical 键。Java 内部字段保持 `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 重复。* +*最后更新: 2026-09-21 — V1.0.1 配置与规则纠偏 + canonical key 字面化:登记 identity.liveness.enabled / profile.photo.maxCount / matching.recommendation.minScore 三项 canonical 键;V051 把活体键收敛为 `feature_flag_overrides` 唯一正式键(KEY_ALIASES 读兼容遗留键);消除 MatchConfig 双 bean 与前后端照片上限失同步。* + diff --git a/docs/governance/known-risks.md b/docs/governance/known-risks.md new file mode 100644 index 0000000..c31bc8e --- /dev/null +++ b/docs/governance/known-risks.md @@ -0,0 +1,20 @@ +# 已知风险 — known-risks.md + +> **版本**:V1.0.1 配置与规则纠偏基线 · **生效**:2026-09-21 · **owner**:WorkBuddy +> 登记「已知但本轮不处置」的风险与债务。每项注明触发条件、影响面、处置去向。 + +--- + +| # | 风险 / 债务 | 影响面 | 处置去向 | +|---|------------|--------|----------| +| KR-01 | **minio pin tag 拉取失败**:`minio/minio:RELEASE.2025-09-07T16-13-09Z` 在本机拉取被拒(疑似上游清理或代理限制);E2E 序列用临时 backend 镜像绕过,S3 用例未触 | 本机 E2E 全栈序列;生产部署镜像拉取 | 换 pin 版本或走可拉取的镜像源;上 CI 后实测。生产 compose 未改。 | +| KR-02 | **`system_configs` 死表**:V001 建表 + seed(match_rule_version/daily_recommendation_limit/match_score_threshold='60'/recommendation_cooldown_days)+ demo-seed 一批 flag 行,**0 个 Java 读取点**;与活键 `match.*` / `features.*` 各写一份(seed 与代码漂移) | 配置可信源混淆(运维改 system_configs 不生效) | 未来迁移裁定:整体退役(DROP 需专门迁移)或接成真实配置源。见 config-drift-audit §一。 | +| KR-03 | **`match_rule_configs` 死表**(V006,含 10 维权重 + minimum_score)+ `MatchRuleConfigMapper.findActive/findByVersion` 无调用方 | 规则版本化走轻量路线(rule_version + V050 threshold_snapshot),此表闲置 | 未来项:接成「规则版本唯一读取源」或删表。见 config-drift-audit §二。 | +| KR-04 | **打分占位未产品化**:`calculateScore` 恒 50.0、互选公式 0.6/0.4、level 分档 80/60 均硬编码(V1 占位) | 推荐分数无业务含义,不得对外包装「匹配成功率」 | 正式化时随 10 维权重一起配置化 + 记入规则版本(open-questions OQ-MATCH-001 关联项)。 | +| KR-05 | **本地资格佐证到期清理作业未实现**:`features.localEvidenceRetentionDays=365` 仅配置,无 @Scheduled 清理任务(文件本体交对象存储生命周期规则兜底) | 佐证文件可能超期留存 | Phase 22 运维任务(Phase-04 报告 :46 登记)。 | +| KR-06 | **GitHub Actions 实测未跑**:E2E 65/65 与回归 733/740 均为本机实测;CI 流水线未推过一次绿 | 「CI 绿」仍依赖一次推送 | 批 3 遗留项(Phase 24 启动前);一次 push 触发。 | +| KR-07 | **本机 WSL/Docker 环境故障(2026-09-21 发现)**:`LxssManager` 服务缺失、`wsl -l` 报「Linux 子系统未安装」,Docker Desktop(WSL2 引擎)无法启动引擎;需管理员权限修复(`wsl --install` / DISM 虚拟机平台) | 本机 docker 容器跑 maven 的路径暂时不可用(已改用宿主机 JDK+Maven 兜底);E2E 活栈序列需 Docker 恢复后重跑 | 用户侧以管理员身份修复 WSL;修复后验证 `danyang-match-backend-test` 镜像仍可用(数据根 `D:\Data\Docker-Windows`)。 | + +--- + +*最后更新: 2026-09-21 — 登记 7 项已知风险(minio / system_configs 死表 / match_rule_configs 死表 / 打分占位 / 佐证清理作业 / CI 实测 / 本机 WSL 故障)。* diff --git a/docs/governance/open-questions.md b/docs/governance/open-questions.md index 101907e..77a648a 100644 --- a/docs/governance/open-questions.md +++ b/docs/governance/open-questions.md @@ -10,11 +10,11 @@ ## OQ-IDENTITY-001 · 是否在正式运营阶段启用人脸/活体核验? - **状态**:TBD -- **当前技术默认**:`identity.liveness.enabled = false`(`features.livenessEnabled`),provider = `none` +- **当前技术默认**:canonical 键 `identity.liveness.enabled`(V051 起为 `feature_flag_overrides` 唯一正式键;实现字段 `livenessEnabled`)= `false`,provider = `none` - **说明**:活体**不是 V1.1 首发强制规则**。默认关闭时不得阻断实名主流程、不得误提示「必须人脸」。 - 本轮已把开关接到真实业务判定点(`GET /verification/liveness-status` → `enhancedVerified`), - 消除「假开关」。是否启用、选用哪家合规第三方 Provider(`OQ` 选型未定)由产品/合规裁决。 -- **关联**:`IdentityPolicy`、`UserIdentity.livenessVerificationStatus`、V016。 + 开关已接真实业务判定点(`GET /verification/liveness-status` → `enhancedVerified`),消除「假开关」。 + 是否启用、选用哪家合规第三方 Provider(选型未定)由产品/合规裁决。 +- **关联**:`IdentityPolicy`、`UserIdentity.livenessVerificationStatus`、V016、V051。 ## OQ-PROFILE-001 · 用户最多可上传多少张个人照片?