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);
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
+4
-1
@@ -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/";
|
||||
|
||||
+113
@@ -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() {
|
||||
|
||||
+2
-1
@@ -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) {
|
||||
|
||||
+31
@@ -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)");
|
||||
}
|
||||
}
|
||||
|
||||
+50
@@ -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 键的覆盖行");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user