V1.0.1 纠偏 DoD 补齐:canonical key 字面化(V051+KEY_ALIASES)+ 照片并发突破实测(锁用户行+锁后计数)+ AGENTS 规则27 + known-risks

按文档 DoD/约束逐条复查后补齐的差距(承接 438b38d 主体纠偏):
- CFG-001 字面化:FeatureFlagConfig.KEY_ALIASES(identity.liveness.enabled / 遗留
  livenessVerification.enabled → 实现字段 livenessEnabled,读兼容)+ Controller PUT/DELETE
  落库归一到 canonical 键 + V051 迁移(删遗留键、写入 identity.liveness.enabled=false);
  MigrationValidationTest 基线 50→51。
- PHOTO-004 真实并发突破:新增 PhotoLimitConcurrencyGuardTest(3 线程 H2 并发冲顶)——
  实测咬住「锁照片行+读时计数」在 H2 MVCC 快照漏读、3/3 全成功突破上限的缺陷;
  并发控制改为锁用户行(UserMapper.lockUserRow FOR UPDATE)+ 锁后新语句 COUNT,
  0 张照片亦有锁可加(补首张并发缺口);UserPhotoMapper 计数方法同步替换。
- FF-LIVENESS-003 + canonical 持久化:FeatureFlagPersistenceGuardTest +4
  (canonical 加载 / 遗留别名读兼容 / PUT 落 canonical 键 / 切换即生效无旧缓存)、
  FeatureFlagControllerTest +3(canonical/legacy/reset)。
- 文档同步:AGENTS.md 规则 27(配置治理纪律);docs/governance/known-risks.md
  (7 项含本机 WSL/Docker 故障 KR-07);config-register / open-questions 更新 canonical 口径;
  STATUS rc.21 行补 ⑥⑦⑧。

全量回归 741/0/0/3(基线 717 + 25 新增 − 1 删死测试,宿主机 JDK21+Maven 3.9.9 实测,
本机 WSL/Docker 引擎故障期间);UI 契约门禁 18/18
This commit is contained in:
WorkBuddy
2026-09-21 11:13:14 +08:00
parent 438b38d121
commit 60c591fc68
18 changed files with 380 additions and 71 deletions
+1
View File
@@ -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)。
---
@@ -18,16 +18,14 @@ public interface UserPhotoMapper extends BaseMapper<UserPhoto> {
UserPhoto findAvatar(String userId);
/**
* 锁定并统计该用户当前 active 的个人照片(头像 + 生活照,未软删)。
* 统计该用户当前 active(未软删)的个人照片数(头像 + 生活照)。
*
* <p>用 {@code SELECT ... FOR UPDATE} 锁住这些行,使同一用户的并发上传被串行化 ——
* 先到的事务提交后,后到的事务在锁上等待、再重新读到最新数量(READ COMMITTED 下
* FOR UPDATE 会做锁后复核),从而「并发上传不可突破上限」。
* 参照 {@code ReviewTaskMapper} 的 {@code FOR UPDATE SKIP LOCKED} 先例(全代码库唯一
* 悲观锁用法)。仅统计头像 + 生活照两类个人照片,本地资格佐证(local_eligibility)
* 另有独立上限,不计入。
* <p>本地资格佐证(local_eligibility)各有独立上限(localEvidenceMaxFiles),不计入。
* 并发安全由调用方保证:先 {@code UserMapper.lockUserRow(userId)}(FOR UPDATE 锁用户行,
* 串行化同一用户并发写、且 0 张照片时也有锁可加),<b>锁后再执行本 COUNT</b>(新语句、
* 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<String> 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);
}
@@ -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 {
* 个人照片数量上限校验(服务器端强制,前端提示不可绕过)。
*
* <p>须在 {@code @Transactional} 事务内调用(调用方 {@link #savePhoto} /
* {@link #uploadAndSave} 均已标注)。用 {@code SELECT ... FOR UPDATE} 锁定该用户
* active 个人照片的行,使同用户并发上传被串行化:先提交的事务释放锁后,后到的事务
* 锁上等待再重读最新数量,从而并发也不可突破上限(PHOTO-004)。
* {@link #uploadAndSave} 均已标注)。并发安全走「用户行锁 + 锁后计数」两步(纠偏方案 §四
* 的 {@code userRepository.lockById + countActivePhotos} 模式):
* <ol>
* <li>{@code UserMapper.lockUserRow}({@code SELECT ... FOR UPDATE})锁住恒存在的用户行 ——
* 串行化同一用户的并发写,且 **0 张照片时也有锁可加**(「首张并发」缺口);</li>
* <li>锁<b>之后</b>再用新语句 {@code COUNT} active 照片(READ COMMITTED 下读到最新已提交数据)。
* 若锁照片行再计数,H2 MVCC 快照可能漏读并发提交的新行(PHOTO-004 实测 3/3 全成功、
* 突破上限的根因),故改为锁用户行。</li>
* </ol>
*
* <p>规则(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(
@@ -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)。
*
* <p>key = 配置中心的<b>正式/遗留键</b>,value = Java 字段名(实现键)。
* 活体核验开关的<b>唯一正式配置键</b>是 {@code identity.liveness.enabled}
* (见 docs/governance/config-register.md,V051 起 feature_flag_overrides 只写该键);
* {@code livenessVerification.enabled} 是 V051 之前的 seed 遗留键,仅作<b>读兼容</b>别名。
* Java 内部字段保持 {@code livenessEnabled}(文档允许:DTO/Policy 内部变量可如此命名)。
*/
public static final Map<String, String> 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);
@@ -52,7 +52,10 @@ public class FeatureFlagController {
@AuthenticationPrincipal OperatorPrincipal principal,
@PathVariable String flagName,
@RequestBody Map<String, Object> 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<Map<String, Object>> 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));
}
}
@@ -22,4 +22,17 @@ public interface UserMapper extends BaseMapper<User> {
@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}),用于把同一用户的并发写串行化。
*
* <p>用法:在事务内先锁用户行、再用<b>新语句</b>读派生计数(如 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);
}
@@ -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'
);
@@ -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/";
@@ -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.*;
/**
* 照片上限<b>真实并发突破</b>守卫(PHOTO-004 / 纠偏方案约束 6:「涉及并发的上限必须测试并发突破」)。
*
* <p>此前 {@link PhotoLimitGuardTest} 用 mock 断言了「计数-拒绝」判定逻辑与「拒绝先于 S3」的
* 顺序,但并发保证本身({@code SELECT ... FOR UPDATE} 串行化同用户并发写)未在真实 DB 上验证。
* 本测试起真实 H2(test profile)+ 真实 {@link PhotoService}(FOR UPDATE 锁),多线程同时冲顶,
* 钉住「最终 active 数量恒 ≤ 上限」这一不变量 —— 这是「并发上传不可突破上限」的可证事实。
*
* <p>注意:本测试<b>不加</b> {@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<UserPhoto>()
.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<Future<Boolean>> 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<Boolean> results = new ArrayList<>();
for (Future<Boolean> 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);
}
}
@@ -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.*;
*
* <p>历史缺陷:{@code AppController} 把「最多 6 张」写死、{@code PhotoService} 上传路径
* 完全没有数量校验 —— 前端提示 6 张但后端不限,前后端规则漂移 + 并发可突破。本守卫用
* mock 的 {@code lockActiveProfilePhotoIds}(FOR UPDATE 锁行数)+ 可配置
* {@code ProfilePolicy} 验证:上限来自服务端配置、lifestyle 超限拒绝、avatar 为槽位替换、
* 本地资格佐证不计入、拒绝发生在对象存储上传之前(不留孤儿对象)、默认值受控非无限。
* mock 的「用户行锁 + 锁后计数」+ 可配置 {@code ProfilePolicy} 验证:
* 上限来自服务端配置、lifestyle 超限拒绝、avatar 为槽位替换、本地资格佐证不计入、
* 拒绝发生在对象存储上传之前(不留孤儿对象)、默认值受控非无限。
*
* <p>说明:真实并发突破由数据库 {@code FOR UPDATE} 串行化保证(见 UserPhotoMapper 注释),
* 单测断言的是「计数-拒绝」判定逻辑与「拒绝先于 S3 上传」的顺序,二者共同覆盖 PHOTO-001~007。
* <p>真实多线程并发突破见 {@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<String> ids(int n) {
List<String> 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());
@@ -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() {
@@ -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) {
@@ -124,4 +124,35 @@ class FeatureFlagControllerTest {
assertFalse(resp.isSuccess());
assertTrue(fixture.isReferralEnabled(), "类型不匹配不应静默把功能置为关闭");
}
// ---- CFG-001:canonical 键 identity.liveness.enabled(V051 起为唯一正式配置键)----
@Test
void updateCanonicalLivenessKeyIsAcceptedAndApplied() {
ApiResponse<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> resp = controller.resetFlag("identity.liveness.enabled");
assertTrue(resp.isSuccess());
assertFalse(fixture.isLivenessEnabled(), "DELETE 后应回到默认 false(fail-safe)");
}
}
@@ -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<Map<String, Object>> 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 键的覆盖行");
}
}
+3 -3
View File
@@ -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** |
---
+4 -3
View File
@@ -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 与前后端照片上限失同步。*
+20
View File
@@ -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 故障)。*
+4 -4
View File
@@ -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 · 用户最多可上传多少张个人照片?