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
@@ -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);
}