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:
@@ -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(
|
||||
|
||||
+37
-1
@@ -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);
|
||||
|
||||
+20
-12
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user