fix(safety): Phase 10 复验 —— 修复图片上传魔数校验死代码(生产故障)/ 高风险词写入日志
对 Phase 10(Image & Chat Safety)执行 KEEP-VERIFY 复验:不采信历史 ✅,逐条核对 图片私有访问、类型/大小/魔数校验、审核与水印、风险识别/举报/违规处理、日志脱敏。 Gate 判定通过,发现并修复 2 处缺陷,并推翻旧报告 2 项安全声明。 D1(Critical,生产故障)图片上传的类型与魔数校验整条是死代码 - PhotoService.uploadAndSave 用 `header = new byte[Math.min(12, stream.available())]` 仅分配零值缓冲,从不调用 stream.read(...) → validateMagicNumber 对任何文件都收到 12 个 0x00 → 恒返回 false → 恒抛"无效的图片文件头"。 - 影响链(已确认线上触发):小程序 local-eligibility-supplement.ts 以 photoType='local_eligibility' 调 POST /photos/upload → 本地资格佐证上传 100% 失败。 - 次生问题:isAllowedMimeType(image-only 白名单)在 local_eligibility 分支之后 无条件执行,即使魔数修好,application/pdf 仍被挡,而 FeatureFlagConfig 的 localEvidenceAllowedTypes 明确允许 pdf;另有未 mark 的 stream.reset() 无效调用。 - 测试为何没发现:PhotoServiceTest 只对 validateMagicNumber 传手写字节做单测, 从不经过 uploadAndSave,该路径缺陷完全无覆盖。 - 修复:改用 try(InputStream){ header = s.readNBytes(12); } 真正读取; MIME 校验按 photoType 分流(local_eligibility 走自己的白名单含 pdf); 魔数校验仅对图片执行、PDF 跳过;移除死 reset();上传用全新流与校验流隔离; generateFileKey 重载按 Content-Type 推断扩展名,PDF 不再拿到误导性 .jpg key。 D2(Low)高风险词写入普通日志 - ChatSafetyService.scanContent 把命中的高风险词原样写入 log.warn, 与同文件 CONTACT 分支"只记 pattern 不记内容"矛盾,且违反本阶段 Gate。 - 修复:改为 log.warn("Chat content matched HIGH_RISK keyword (blocked)")。 推翻的旧声明(docs/Phase-10-Completion-Report.md) - 「伪装恶意图片被拒(Magic Number校验)」不成立:该校验运行时恒失败, 连合法图片也一律被拒(fail-closed 到不可用),安全属性并非由它提供。 - 「EXIF清理预留(PhotoService.stripExif占位)」不存在:全仓库无该符号。 且 getUploadToken 客户端直传路径服务端无法介入清理 EXIF。 - 已在旧报告顶部加状态注记,指向本次复验报告。 测试 - 新增 PhotoUploadValidationTest(6 用例),用 MockMultipartFile 真正走一遍 uploadAndSave:合法 JPEG/PNG 通过且确实调用 putObject、伪造 Content-Type 被魔数挡、 PDF 佐证不被 image-only 挡且 key 以 .pdf 结尾、非法 MIME 被拒、超大被拒。 - 负向验证:临时把魔数守卫改为 if(false && ...) → 恰好 spoofedContentTypeRejectedByMagic 一个用例变红,其余 5 个仍绿;随后还原并 grep 确认无 NEGATIVE-CHECK-TEMP 残留。 - 全量回归(离线容器 danyang-match-backend-test,mvn -o test): 334 tests / 0 failures / 0 errors / 3 skipped / BUILD SUCCESS(基线 328,+6)。 登记 gap(不自动实现,待产品确认) - 图片水印:全仓库无实现;主计划未列硬需求,仅"开发执行计划修订版"提"高级动态水印"。 - EXIF 清理:旧声明不存在;客户端直传路径服务端无法介入,需改为接收侧重编码。 - 直传凭证路径(getUploadToken)无服务端内容校验且不落 DB 记录 → 后续 getSignedUrl 归属校验会查不到 UserPhoto 行,属契约层缺口,超出本阶段安全 Gate 范围。 - 违规自动升级:ChatSafetyService 注释明确属 Phase 17 范围,当前 high 仅拦截不落库。 文档:新增 Phase-10-V11-Recovery-Completion-Report-2026-09-16.md; 旧报告加状态注记;主计划 Phase 10 复选框置 ✅;STATUS / JIRA_BACKLOG 同步(334 tests)。 Phase 11 未启动,按计划每阶段出报告后停止等待确认。
This commit is contained in:
+2
-1
@@ -77,7 +77,8 @@ public class ChatSafetyService {
|
|||||||
// 1. 高风险关键词
|
// 1. 高风险关键词
|
||||||
for (String word : HIGH_RISK_WORDS) {
|
for (String word : HIGH_RISK_WORDS) {
|
||||||
if (content.contains(word)) {
|
if (content.contains(word)) {
|
||||||
log.warn("Chat content matched HIGH_RISK word: '{}'", word);
|
// 不记录命中的具体词汇,避免将聊天内容片段写入普通日志(Phase 10 Gate)
|
||||||
|
log.warn("Chat content matched HIGH_RISK keyword (blocked)");
|
||||||
return ScanResult.high("检测到可能的诈骗/违规内容,消息已拦截");
|
return ScanResult.high("检测到可能的诈骗/违规内容,消息已拦截");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -126,39 +126,39 @@ public class PhotoService {
|
|||||||
*/
|
*/
|
||||||
@Transactional
|
@Transactional
|
||||||
public UserPhoto uploadAndSave(String userId, MultipartFile file, String photoType, int sortOrder) {
|
public UserPhoto uploadAndSave(String userId, MultipartFile file, String photoType, int sortOrder) {
|
||||||
// 1. 安全校验
|
// 1. 类型与大小校验
|
||||||
if ("local_eligibility".equals(photoType) && !isAllowedLocalEvidenceMimeType(file.getContentType())) {
|
// local_eligibility 允许 PDF 等佐证格式,有自己的白名单;其余走图片白名单。
|
||||||
throw new RuntimeException("不支持的佐证文件格式: " + file.getContentType());
|
// 历史缺陷:此前两条 MIME 校验串行执行,PDF 会被图片白名单挡掉;魔数校验又因
|
||||||
}
|
// new byte[n] 从不读取流内容而恒判失败,导致本地资格佐证上传 100% 失败。
|
||||||
if (!isAllowedMimeType(file.getContentType())) {
|
String contentType = file.getContentType();
|
||||||
throw new RuntimeException("不支持的图片格式: " + file.getContentType());
|
boolean isLocalEvidence = "local_eligibility".equals(photoType);
|
||||||
|
if (isLocalEvidence) {
|
||||||
|
if (!isAllowedLocalEvidenceMimeType(contentType)) {
|
||||||
|
throw new RuntimeException("不支持的佐证文件格式: " + contentType);
|
||||||
|
}
|
||||||
|
} else if (!isAllowedMimeType(contentType)) {
|
||||||
|
throw new RuntimeException("不支持的图片格式: " + contentType);
|
||||||
}
|
}
|
||||||
if (!validateSize(file, photoType)) {
|
if (!validateSize(file, photoType)) {
|
||||||
throw new RuntimeException("图片大小超过限制(最大10MB)");
|
throw new RuntimeException("文件大小超过限制");
|
||||||
}
|
|
||||||
InputStream stream;
|
|
||||||
try {
|
|
||||||
stream = file.getInputStream();
|
|
||||||
} catch (IOException e) {
|
|
||||||
throw new RuntimeException("读取文件失败", e);
|
|
||||||
}
|
|
||||||
byte[] header;
|
|
||||||
try {
|
|
||||||
header = new byte[Math.min(12, stream.available())];
|
|
||||||
} catch (IOException e) {
|
|
||||||
throw new RuntimeException("读取文件头失败", e);
|
|
||||||
}
|
|
||||||
if (!validateMagicNumber(header)) {
|
|
||||||
throw new RuntimeException("无效的图片文件头");
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
stream.reset();
|
|
||||||
} catch (IOException e) {
|
|
||||||
throw new RuntimeException("重置输入流失败", e);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 生成 key 并上传
|
// 2. 魔数校验(仅图片;PDF 佐证跳过——Content-Type 已由白名单约束,
|
||||||
String fileKey = generateFileKey(userId, photoType);
|
// 且其魔数 %PDF- 与图片魔数不同,混用同一校验会误杀)
|
||||||
|
if (!isLocalEvidence) {
|
||||||
|
byte[] header;
|
||||||
|
try (InputStream headerStream = file.getInputStream()) {
|
||||||
|
header = headerStream.readNBytes(12);
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new RuntimeException("读取文件头失败", e);
|
||||||
|
}
|
||||||
|
if (header.length < 3 || !validateMagicNumber(header)) {
|
||||||
|
throw new RuntimeException("无效的图片文件头");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 生成 key 并上传(使用全新输入流,与校验流隔离,互不干扰)
|
||||||
|
String fileKey = generateFileKey(userId, photoType, contentType);
|
||||||
try {
|
try {
|
||||||
amazonS3.putObject(privateBucket, fileKey, file.getInputStream(),
|
amazonS3.putObject(privateBucket, fileKey, file.getInputStream(),
|
||||||
new com.amazonaws.services.s3.model.ObjectMetadata() {{
|
new com.amazonaws.services.s3.model.ObjectMetadata() {{
|
||||||
@@ -169,7 +169,7 @@ public class PhotoService {
|
|||||||
throw new RuntimeException("上传文件失败", e);
|
throw new RuntimeException("上传文件失败", e);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 保存记录
|
// 4. 保存记录
|
||||||
return savePhoto(userId, fileKey, photoType, sortOrder);
|
return savePhoto(userId, fileKey, photoType, sortOrder);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -285,8 +285,27 @@ public class PhotoService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private String generateFileKey(String userId, String photoType) {
|
private String generateFileKey(String userId, String photoType) {
|
||||||
// 从文件头推断扩展名
|
// 直传凭证路径:客户端尚未上传,Content-Type 未知,沿用默认扩展名
|
||||||
String ext = ".jpg";
|
return String.format("photos/%s/%s/%s.jpg",
|
||||||
|
userId,
|
||||||
|
photoType,
|
||||||
|
UUID.randomUUID().toString().replace("-", ""));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按 Content-Type 推断扩展名,避免 PDF 佐证拿到 .jpg 这类误导性 key
|
||||||
|
* (对象元数据虽记录了正确的 Content-Type,但 key 后缀仍会被审核下载复用)。
|
||||||
|
*/
|
||||||
|
private String generateFileKey(String userId, String photoType, String contentType) {
|
||||||
|
String ext = switch (contentType == null ? "" : contentType.toLowerCase()) {
|
||||||
|
case "image/jpeg" -> ".jpg";
|
||||||
|
case "image/png" -> ".png";
|
||||||
|
case "image/webp" -> ".webp";
|
||||||
|
case "image/gif" -> ".gif";
|
||||||
|
case "image/heic" -> ".heic";
|
||||||
|
case "application/pdf" -> ".pdf";
|
||||||
|
default -> "";
|
||||||
|
};
|
||||||
return String.format("photos/%s/%s/%s%s",
|
return String.format("photos/%s/%s/%s%s",
|
||||||
userId,
|
userId,
|
||||||
photoType,
|
photoType,
|
||||||
|
|||||||
+165
@@ -0,0 +1,165 @@
|
|||||||
|
package com.danyang.match.platform.photo;
|
||||||
|
|
||||||
|
import com.amazonaws.services.s3.AmazonS3;
|
||||||
|
import com.amazonaws.services.s3.model.ObjectMetadata;
|
||||||
|
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.config.FeatureFlagConfig;
|
||||||
|
import org.junit.jupiter.api.DisplayName;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.mock.web.MockMultipartFile;
|
||||||
|
|
||||||
|
import java.io.InputStream;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 图片上传安全校验测试(Phase 10: Image & Chat Safety)。
|
||||||
|
*
|
||||||
|
* <p>历史缺陷(D1):{@code uploadAndSave} 此前用 {@code new byte[n]} 分配缓冲却从不
|
||||||
|
* 调用 {@code stream.read(...)},{@code validateMagicNumber} 恒收到全零字节 → 恒返回 false
|
||||||
|
* → 本地资格佐证上传 100% 失败;同时 image-only 的 {@code isAllowedMimeType} 在
|
||||||
|
* local_eligibility 之后无条件执行,PDF 被挡。本测试用 {@link MockMultipartFile} 真正走一遍
|
||||||
|
* 校验链路,确保魔数/MIME/大小守卫在运行时实际生效——单测 {@code validateMagicNumber} 本身
|
||||||
|
* (传入手写字节)无法暴露这条路径缺陷。
|
||||||
|
*/
|
||||||
|
class PhotoUploadValidationTest {
|
||||||
|
|
||||||
|
/** JPEG 魔数:FF D8 FF E0 + 最小可用体 */
|
||||||
|
private static final byte[] JPEG_BYTES = {
|
||||||
|
(byte) 0xFF, (byte) 0xD8, (byte) 0xFF, (byte) 0xE0,
|
||||||
|
0, 0, 0, 0, 0, 0, 0, 0
|
||||||
|
};
|
||||||
|
|
||||||
|
private PhotoService newService(AmazonS3 s3, UserPhotoMapper mapper, FeatureFlagConfig flags) {
|
||||||
|
return new PhotoService(mapper, s3, flags);
|
||||||
|
}
|
||||||
|
|
||||||
|
private FeatureFlagConfig localEvidenceFlags(int maxMB, String allowedTypes) {
|
||||||
|
FeatureFlagConfig f = mock(FeatureFlagConfig.class);
|
||||||
|
when(f.getLocalEvidenceMaxFileSizeMB()).thenReturn(maxMB);
|
||||||
|
when(f.getLocalEvidenceAllowedTypes()).thenReturn(allowedTypes);
|
||||||
|
return f;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("合法 JPEG 通过 MIME + 魔数校验(修复前此用例会因 header 恒零而失败)")
|
||||||
|
void validJpegPassesValidation() {
|
||||||
|
AmazonS3 s3 = mock(AmazonS3.class);
|
||||||
|
UserPhotoMapper mapper = mock(UserPhotoMapper.class);
|
||||||
|
when(mapper.insert(any(UserPhoto.class))).thenReturn(1);
|
||||||
|
PhotoService svc = newService(s3, mapper, null);
|
||||||
|
|
||||||
|
MockMultipartFile file = new MockMultipartFile(
|
||||||
|
"file", "a.jpg", "image/jpeg", JPEG_BYTES);
|
||||||
|
|
||||||
|
UserPhoto result = svc.uploadAndSave("u1", file, "lifestyle", 0);
|
||||||
|
|
||||||
|
assertNotNull(result);
|
||||||
|
verify(s3).putObject(any(), anyString(), any(InputStream.class), any(ObjectMetadata.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("伪造 Content-Type(声称 image/jpeg 实为文本)被魔数校验拦截")
|
||||||
|
void spoofedContentTypeRejectedByMagic() {
|
||||||
|
AmazonS3 s3 = mock(AmazonS3.class);
|
||||||
|
UserPhotoMapper mapper = mock(UserPhotoMapper.class);
|
||||||
|
PhotoService svc = newService(s3, mapper, null);
|
||||||
|
|
||||||
|
// 声称是 JPEG,实际内容是纯文本
|
||||||
|
MockMultipartFile file = new MockMultipartFile(
|
||||||
|
"file", "evil.jpg", "image/jpeg", "not a real image".getBytes());
|
||||||
|
|
||||||
|
RuntimeException ex = assertThrows(RuntimeException.class,
|
||||||
|
() -> svc.uploadAndSave("u1", file, "lifestyle", 0));
|
||||||
|
assertTrue(ex.getMessage().contains("无效的图片文件头"),
|
||||||
|
"应因魔数不匹配被拒,实际: " + ex.getMessage());
|
||||||
|
verify(s3, never()).putObject(any(), anyString(), any(InputStream.class), any(ObjectMetadata.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("PDF 本地资格佐证不被 image-only 校验挡,且跳过图片魔数校验")
|
||||||
|
void pdfLocalEligibilityNotBlocked() {
|
||||||
|
AmazonS3 s3 = mock(AmazonS3.class);
|
||||||
|
UserPhotoMapper mapper = mock(UserPhotoMapper.class);
|
||||||
|
when(mapper.insert(any(UserPhoto.class))).thenReturn(1);
|
||||||
|
FeatureFlagConfig flags = localEvidenceFlags(10, "jpg,png,pdf");
|
||||||
|
PhotoService svc = newService(s3, mapper, flags);
|
||||||
|
|
||||||
|
// application/pdf 走 local_eligibility 白名单(含 pdf),不应被 image-only 挡
|
||||||
|
MockMultipartFile file = new MockMultipartFile(
|
||||||
|
"file", "evidence.pdf", "application/pdf",
|
||||||
|
"%PDF-1.4 fake pdf content".getBytes());
|
||||||
|
|
||||||
|
UserPhoto result = svc.uploadAndSave("u1", file, "local_eligibility", 0);
|
||||||
|
|
||||||
|
assertNotNull(result);
|
||||||
|
assertTrue(result.getFileKey().endsWith(".pdf"),
|
||||||
|
"PDF key 应以 .pdf 结尾,实际: " + result.getFileKey());
|
||||||
|
verify(s3).putObject(any(), anyString(), any(InputStream.class), any(ObjectMetadata.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("非法 MIME 类型被拒(text/plain 当作 lifestyle 图片)")
|
||||||
|
void invalidMimeTypeRejected() {
|
||||||
|
AmazonS3 s3 = mock(AmazonS3.class);
|
||||||
|
UserPhotoMapper mapper = mock(UserPhotoMapper.class);
|
||||||
|
PhotoService svc = newService(s3, mapper, null);
|
||||||
|
|
||||||
|
MockMultipartFile file = new MockMultipartFile(
|
||||||
|
"file", "a.txt", "text/plain", JPEG_BYTES);
|
||||||
|
|
||||||
|
RuntimeException ex = assertThrows(RuntimeException.class,
|
||||||
|
() -> svc.uploadAndSave("u1", file, "lifestyle", 0));
|
||||||
|
assertTrue(ex.getMessage().contains("不支持的图片格式"),
|
||||||
|
"应因 MIME 不在白名单被拒,实际: " + ex.getMessage());
|
||||||
|
verify(s3, never()).putObject(any(), anyString(), any(InputStream.class), any(ObjectMetadata.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("超过大小限制被拒(local_eligibility 限额 1MB,传入 2MB)")
|
||||||
|
void oversizedFileRejected() {
|
||||||
|
AmazonS3 s3 = mock(AmazonS3.class);
|
||||||
|
UserPhotoMapper mapper = mock(UserPhotoMapper.class);
|
||||||
|
FeatureFlagConfig flags = localEvidenceFlags(1, "pdf");
|
||||||
|
PhotoService svc = newService(s3, mapper, flags);
|
||||||
|
|
||||||
|
// 2MB 的 PDF 佐证,限额 1MB
|
||||||
|
byte[] big = new byte[2 * 1024 * 1024];
|
||||||
|
big[0] = '%';
|
||||||
|
MockMultipartFile file = new MockMultipartFile(
|
||||||
|
"file", "big.pdf", "application/pdf", big);
|
||||||
|
|
||||||
|
RuntimeException ex = assertThrows(RuntimeException.class,
|
||||||
|
() -> svc.uploadAndSave("u1", file, "local_eligibility", 0));
|
||||||
|
assertTrue(ex.getMessage().contains("文件大小超过限制"),
|
||||||
|
"应因超限被拒,实际: " + ex.getMessage());
|
||||||
|
verify(s3, never()).putObject(any(), anyString(), any(InputStream.class), any(ObjectMetadata.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("PNG 魔数校验通过")
|
||||||
|
void validPngPassesValidation() {
|
||||||
|
AmazonS3 s3 = mock(AmazonS3.class);
|
||||||
|
UserPhotoMapper mapper = mock(UserPhotoMapper.class);
|
||||||
|
when(mapper.insert(any(UserPhoto.class))).thenReturn(1);
|
||||||
|
PhotoService svc = newService(s3, mapper, null);
|
||||||
|
|
||||||
|
byte[] png = {
|
||||||
|
(byte) 0x89, (byte) 0x50, (byte) 0x4E, (byte) 0x47,
|
||||||
|
(byte) 0x0D, (byte) 0x0A, (byte) 0x1A, (byte) 0x0A,
|
||||||
|
0, 0, 0, 0
|
||||||
|
};
|
||||||
|
MockMultipartFile file = new MockMultipartFile(
|
||||||
|
"file", "a.png", "image/png", png);
|
||||||
|
|
||||||
|
UserPhoto result = svc.uploadAndSave("u1", file, "lifestyle", 0);
|
||||||
|
assertNotNull(result);
|
||||||
|
assertTrue(result.getFileKey().endsWith(".png"));
|
||||||
|
verify(s3).putObject(any(), anyString(), any(InputStream.class), any(ObjectMetadata.class));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,9 +5,16 @@
|
|||||||
|
|
||||||
> **2026-09-16 状态校正:** Phase 07 UI 已按 2026-09-13 规格重新实现,**首批 11 页于 2026-09-16 通过用户验收(结论:基本达标)**,本文件此前"视觉否决/REOPENED"口径随之关闭。
|
> **2026-09-16 状态校正:** Phase 07 UI 已按 2026-09-13 规格重新实现,**首批 11 页于 2026-09-16 通过用户验收(结论:基本达标)**,本文件此前"视觉否决/REOPENED"口径随之关闭。
|
||||||
>
|
>
|
||||||
|
> **2026-09-16 Phase 10 复验关闭:** EPIC-P10-V11 已完成。复验中发现并修复 2 处缺陷——
|
||||||
|
> **D1(Critical,生产故障)** `PhotoService.uploadAndSave` 魔数校验因 `new byte[n]` 从不读取流而恒失败,本地资格佐证上传 100% 失败(且 PDF 被 image-only MIME 挡);
|
||||||
|
> **D2(Low)** `ChatSafetyService` 把命中的高风险词写入普通日志。
|
||||||
|
> 同时**推翻旧报告**「伪装恶意图片被拒(Magic Number 校验)」与「EXIF清理预留(stripExif 占位)」两项声明——后者符号全仓库不存在。
|
||||||
|
> 新增 `PhotoUploadValidationTest`(6 用例,`MockMultipartFile` 真走校验链路)并做负向验证。后端全量 **334 tests / 0 failures / 0 errors**。
|
||||||
|
> 证据:`docs/Phase-10-V11-Recovery-Completion-Report-2026-09-16.md`。**Phase 11 需用户确认后启动。**
|
||||||
|
>
|
||||||
> **2026-09-16 Phase 09 复验关闭:** EPIC-P09-V11 已完成。复验中发现并修复 4 处真实缺陷(WebSocket 下行整条链路为死代码、Unmatch 后仍可发消息、Block 未落库 Match、聊天四接口 IDOR 越权),
|
> **2026-09-16 Phase 09 复验关闭:** EPIC-P09-V11 已完成。复验中发现并修复 4 处真实缺陷(WebSocket 下行整条链路为死代码、Unmatch 后仍可发消息、Block 未落库 Match、聊天四接口 IDOR 越权),
|
||||||
> 新增 `ChatPhase09IntegrationTest`(13 用例)与 `ChatWebSocketHandlerTest`(7 用例),并把生产消息唯一索引下沉到测试库。后端全量 **328 tests / 0 failures / 0 errors**。
|
> 新增 `ChatPhase09IntegrationTest`(13 用例)与 `ChatWebSocketHandlerTest`(7 用例),并把生产消息唯一索引下沉到测试库。后端全量 **328 tests / 0 failures / 0 errors**。
|
||||||
> 证据:`docs/Phase-09-V11-Recovery-Completion-Report-2026-09-16.md`。**Phase 10 需用户确认后启动。**
|
> 证据:`docs/Phase-09-V11-Recovery-Completion-Report-2026-09-16.md`。
|
||||||
>
|
>
|
||||||
> **2026-09-16 Phase 08 复验关闭:** EPIC-P08-V11 已完成。复验中发现并修复 3 处真实缺陷(搜索绕过 Block / 详情无 Block 检查 / `matches.source` 取值越界),
|
> **2026-09-16 Phase 08 复验关闭:** EPIC-P08-V11 已完成。复验中发现并修复 3 处真实缺陷(搜索绕过 Block / 详情无 Block 检查 / `matches.source` 取值越界),
|
||||||
> 新增 `DiscoverBlockInvisibilityIntegrationTest`(9 用例)并把生产 `chk_match_source` 约束下沉到测试库。
|
> 新增 `DiscoverBlockInvisibilityIntegrationTest`(9 用例)并把生产 `chk_match_source` 约束下沉到测试库。
|
||||||
@@ -26,6 +33,7 @@
|
|||||||
| Phase 07 V1.1 首页唯一实现 + 找对象 | 已完成:视觉已通过用户验收(2026-09-16) | 保留首页聚合、Capability、8 入口和推荐规则证据;UI 已按 2026-09-13 新规格重新实现,11 页经用户验收通过 |
|
| Phase 07 V1.1 首页唯一实现 + 找对象 | 已完成:视觉已通过用户验收(2026-09-16) | 保留首页聚合、Capability、8 入口和推荐规则证据;UI 已按 2026-09-13 新规格重新实现,11 页经用户验收通过 |
|
||||||
| Phase 08 Like / Match / Block 复验 | 已完成(2026-09-16) | 见 `Phase-08-V11-Recovery-Completion-Report-2026-09-16.md`;修复 3 处真实缺陷(搜索绕过 Block、详情无 Block 检查、`matches.source` 取值越界),后端 308 tests 全绿 |
|
| Phase 08 Like / Match / Block 复验 | 已完成(2026-09-16) | 见 `Phase-08-V11-Recovery-Completion-Report-2026-09-16.md`;修复 3 处真实缺陷(搜索绕过 Block、详情无 Block 检查、`matches.source` 取值越界),后端 308 tests 全绿 |
|
||||||
| Phase 09 Text Chat 复验 | 已完成(2026-09-16) | 见 `Phase-09-V11-Recovery-Completion-Report-2026-09-16.md`;修复 4 处真实缺陷(WebSocket 下行死代码、Unmatch 后仍可发消息、Block 未落库 Match、聊天接口 IDOR),后端 328 tests 全绿;Phase 10 尚未获准 |
|
| Phase 09 Text Chat 复验 | 已完成(2026-09-16) | 见 `Phase-09-V11-Recovery-Completion-Report-2026-09-16.md`;修复 4 处真实缺陷(WebSocket 下行死代码、Unmatch 后仍可发消息、Block 未落库 Match、聊天接口 IDOR),后端 328 tests 全绿;Phase 10 尚未获准 |
|
||||||
|
| Phase 10 Image & Chat Safety 复验 | 已完成(2026-09-16) | 见 `Phase-10-V11-Recovery-Completion-Report-2026-09-16.md`;修复 2 处缺陷(图片上传魔数校验死代码致本地资格佐证上传 100% 失败、高风险词写入日志),推翻旧报告 2 项安全声明,后端 334 tests 全绿;Phase 11 尚未获准 |
|
||||||
| UI-CLEANUP-20260913 | 已完成 | 废弃 UI 代码、冲突规范、旧计划和无效验收证据已清理;`ui-document-baseline-contracts` 锁定唯一视觉入口 |
|
| UI-CLEANUP-20260913 | 已完成 | 废弃 UI 代码、冲突规范、旧计划和无效验收证据已清理;`ui-document-baseline-contracts` 锁定唯一视觉入口 |
|
||||||
| UI-ADMIN-20260913 | 已完成(待桌面端目视复验) | 管理端全局框架与工作台已按规格交付;18 modules + 22 routes,vue-tsc 与 vite build 全绿 |
|
| UI-ADMIN-20260913 | 已完成(待桌面端目视复验) | 管理端全局框架与工作台已按规格交付;18 modules + 22 routes,vue-tsc 与 vite build 全绿 |
|
||||||
| UI-MINIAPP-20260913 | 已完成 | 小程序 11 个核心页面已重构;11/11 页 TS-only,2026-09-16 通过用户验收 |
|
| UI-MINIAPP-20260913 | 已完成 | 小程序 11 个核心页面已重构;11/11 页 TS-only,2026-09-16 通过用户验收 |
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
# Phase 10 Completion Report
|
# Phase 10 Completion Report
|
||||||
|
|
||||||
|
> ⚠️ **状态注记(2026-09-16)**:本报告已被 `Phase-10-V11-Recovery-Completion-Report-2026-09-16.md` 复验并部分推翻。
|
||||||
|
> 复验发现 **D1(Critical)**:`PhotoService.uploadAndSave` 的魔数校验因 `new byte[n]` 从不读取流内容而**恒失败**,
|
||||||
|
> 本地资格佐证上传 100% 失败;本文档 "Security Review" 中「伪装恶意图片被拒(Magic Number校验)」一项**当时并不成立**。
|
||||||
|
> 另:「EXIF清理预留(PhotoService.stripExif占位)」全仓库无对应符号,属未落地声明。
|
||||||
|
> 本文档其余进度声明仅作历史记录,请以复验报告为准。
|
||||||
|
|
||||||
## Status
|
## Status
|
||||||
✅ Completed
|
✅ Completed
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
# Phase 10(Image & Chat Safety)复验报告
|
||||||
|
|
||||||
|
> 日期:2026-09-16
|
||||||
|
> 方法:KEEP-VERIFY —— 不采信历史 ✅,逐条核对运行时路径
|
||||||
|
> 结论:**Gate 通过**,但复验推翻了旧报告的一项安全声明,并修复 1 处 Critical 生产故障
|
||||||
|
> 基线:`03a03c7`(Phase 09 关闭)→ 本阶段提交见文末
|
||||||
|
> 全量回归:**334 tests / 0 failures / 0 errors / 3 skipped / BUILD SUCCESS**(基线 328,+6)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Gate 判定
|
||||||
|
|
||||||
|
| Gate 要求 | 判定 | 证据 |
|
||||||
|
|---|---|---|
|
||||||
|
| 越权图片访问被拒绝 | ✅ 通过 | `PhotoService.getSignedUrl` 按 `fileKey` 比对归属,非本人抛异常;`getSignedUrlForAuthorizedReview` 全仓库仅 `ReviewService` 调用(后台审核流,调用前已完成业务权限与资源归属校验) |
|
||||||
|
| 敏感内容不进入普通日志 | ✅ 通过(修复后) | 聊天正文/手机号/身份证/签名 URL 均未落日志;`AuthService` 手机号走 `maskPhone`。**修复前** `ChatSafetyService` 把命中的高风险词原样写入 warn(D2,已修) |
|
||||||
|
| 图片类型/大小校验 | ✅ 通过(修复后) | **修复前该路径是死的**(D1)。修复后 `uploadAndSave` 真正读取文件头做魔数校验,MIME 按 photoType 分流 |
|
||||||
|
| 风险识别 / 举报入口 / 违规处理 | ✅ 通过 | `ChatSafetyService`(限频+关键词+正则)→ `ChatService.sendText` 打标+RiskNotice;`ReportService` 提交→评分→处置动作→审计→通知链路完整 |
|
||||||
|
| 审核 | ✅ 通过 | `ReviewService` 审核任务全状态机(pending/processing/approved/need_edit/rejected)在,照片 `reviewStatus` 置 pending |
|
||||||
|
| 水印 | ⚠️ 未实现(登记 gap) | 全仓库 `backend/src/main/java` 无任何水印实现。V1.1 主计划未列为硬需求;仅"开发执行计划修订版"/"Jira 清单"提及"高级水印"(属后续增强) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 发现与修复
|
||||||
|
|
||||||
|
### D1(Critical)图片上传的类型与魔数校验整条是死代码 —— 生产故障
|
||||||
|
|
||||||
|
**位置**:`PhotoService.uploadAndSave`(`PhotoService.java` 原 145–158 行)
|
||||||
|
|
||||||
|
**根因**:
|
||||||
|
|
||||||
|
```java
|
||||||
|
byte[] header;
|
||||||
|
try {
|
||||||
|
header = new byte[Math.min(12, stream.available())]; // 只分配缓冲,从不读取!
|
||||||
|
} catch (IOException e) { ... }
|
||||||
|
if (!validateMagicNumber(header)) { // header 恒为全零
|
||||||
|
throw new RuntimeException("无效的图片文件头"); // → 恒真 → 恒抛
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`new byte[n]` 仅分配零值数组,**没有任何 `stream.read(...)`**。`validateMagicNumber` 收到的永远是 12 个 `0x00`,对任何文件都返回 `false`。
|
||||||
|
|
||||||
|
**影响链**(非理论风险,已确认为线上触发路径):
|
||||||
|
|
||||||
|
- 小程序 `local-eligibility-supplement.ts:98` 以 `photoType: 'local_eligibility'` 调 `POST /api/v1/photos/upload` → 命中 `uploadAndSave`。
|
||||||
|
- 该路径**对所有文件类型 100% 抛"无效的图片文件头"**,本地资格佐证上传功能完全不可用。
|
||||||
|
- 同时 `isAllowedMimeType`(image-only 白名单:jpeg/png/webp/gif/heic)在 `local_eligibility` 分支之后**无条件执行**,即使魔数问题修好,`application/pdf` 仍会被挡——而 `FeatureFlagConfig.getLocalEvidenceAllowedTypes()` 明确允许 pdf。
|
||||||
|
|
||||||
|
**次生问题**:`stream.reset()` 被调用但 `getInputStream()` 未 `mark()`(`MultipartFile` 契约不保证 `markSupported()`),属无效调用。
|
||||||
|
|
||||||
|
**测试为何没发现**:`PhotoServiceTest` 只对 `validateMagicNumber` 传入**手写字节数组**做单元测试(`{(byte)0xFF,(byte)0xD8,...}`),从不经过 `uploadAndSave`,因此该路径的读取缺陷完全没有覆盖。
|
||||||
|
|
||||||
|
**修复**(`PhotoService.java`):
|
||||||
|
|
||||||
|
1. 用 `try (InputStream s = file.getInputStream()) { header = s.readNBytes(12); }` **真正读取**文件头;`header.length < 3` 直接判失败。
|
||||||
|
2. MIME 校验**按 photoType 分流**:`local_eligibility` 走 `isAllowedLocalEvidenceMimeType`(含 pdf),其余走图片白名单——不再串行双校验。
|
||||||
|
3. 魔数校验**仅对图片执行**,PDF 佐证跳过(Content-Type 已由白名单约束,其 `%PDF-` 魔数与图片不同,混用会误杀)。
|
||||||
|
4. 移除无 `mark` 的 `reset()` 死调用;上传使用全新输入流,与校验流隔离。
|
||||||
|
5. `generateFileKey` 重载按 Content-Type 推断扩展名,PDF 佐证不再拿到误导性的 `.jpg` key。
|
||||||
|
|
||||||
|
### D2(Low)高风险词写入普通日志
|
||||||
|
|
||||||
|
**位置**:`ChatSafetyService.scanContent`(原 80 行)
|
||||||
|
|
||||||
|
```java
|
||||||
|
log.warn("Chat content matched HIGH_RISK word: '{}'", word); // 把聊天内容片段写进日志
|
||||||
|
```
|
||||||
|
|
||||||
|
与同文件 `CONTACT_PATTERNS` 分支的 `log.info("Chat content matched CONTACT pattern")`(不记具体内容)自相矛盾,且直接违反本阶段 Gate「敏感内容不进入普通日志」。
|
||||||
|
|
||||||
|
**修复**:改为 `log.warn("Chat content matched HIGH_RISK keyword (blocked)")`,不记录命中的具体词汇。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 复验推翻的旧声明
|
||||||
|
|
||||||
|
`docs/Phase-10-Completion-Report.md` 的 "Security Review" 写着:
|
||||||
|
|
||||||
|
> - [x] 伪装恶意图片被拒(Magic Number校验)
|
||||||
|
|
||||||
|
**该声明不成立**:魔数校验在运行时恒失败——不是说"被拒的没拒",而是**连合法图片也一律被拒**(fail-closed 到不可用),安全属性并非由该校验提供。旧报告同时自认 `Known Issues: 图片上传实际对接需在 Phase 10 后续完善`,说明作者当时已知未通。
|
||||||
|
|
||||||
|
同报告另有两条不可验证的"框架"声明,本次一并对齐现状:
|
||||||
|
|
||||||
|
| 旧声明 | 复验结论 |
|
||||||
|
|---|---|
|
||||||
|
| `EXIF清理预留(PhotoService.stripExif占位)` | **不存在**。全仓库 `backend/src/main/java` 无 `stripExif` 符号。且 `getUploadToken` 客户端直传路径服务端无法介入清理 EXIF(真正实现需在接收侧重编码或引入图像库)。 |
|
||||||
|
| `WebSocket推送占位(Phase 09 Handler已就绪)` | Phase 09 已证实当时 Handler 整条下行链路是死代码;本阶段已随 Phase 09 修复。 |
|
||||||
|
| `限流框架(Redis限流预留)` | 已由 `ChatSafetyService.checkRateLimit` 落地(Redis + fail-open),非"预留"。 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 测试保真度
|
||||||
|
|
||||||
|
新增 `PhotoUploadValidationTest`(6 用例),**用 `MockMultipartFile` 真正走一遍 `uploadAndSave`**:
|
||||||
|
|
||||||
|
| 用例 | 验证点 |
|
||||||
|
|---|---|
|
||||||
|
| `validJpegPassesValidation` | 合法 JPEG 通过 MIME + 魔数校验,且确实调用 `putObject`(修复前此用例失败,正是 D1 的抓取器) |
|
||||||
|
| `spoofedContentTypeRejectedByMagic` | 声称 `image/jpeg` 实为文本 → 被魔数挡,且**不调用** `putObject` |
|
||||||
|
| `pdfLocalEligibilityNotBlocked` | `application/pdf` 走 local_eligibility 白名单不被 image-only 挡,key 以 `.pdf` 结尾 |
|
||||||
|
| `invalidMimeTypeRejected` | `text/plain` 作 lifestyle → 因 MIME 不在白名单被拒 |
|
||||||
|
| `oversizedFileRejected` | 限额 1MB 传 2MB → 被拒 |
|
||||||
|
| `validPngPassesValidation` | PNG 魔数通过,key 以 `.png` 结尾 |
|
||||||
|
|
||||||
|
**负向验证**:临时把 `uploadAndSave` 中的魔数守卫改为 `if (false && ...)`,重跑 → 恰好 `spoofedContentTypeRejectedByMagic` 一个用例变红(伪造图被放行),其余 5 个仍绿,证明守卫精准生效且不误报;随后还原并 `grep -rn "NEGATIVE-CHECK-TEMP"` 确认无残留。
|
||||||
|
|
||||||
|
**全量回归**:`334 tests / 0 failures / 0 errors / 3 skipped / BUILD SUCCESS`(基线 328,+6)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 登记 gap(不自动实现)
|
||||||
|
|
||||||
|
| 项 | 说明 | 建议归属 |
|
||||||
|
|---|---|---|
|
||||||
|
| 图片水印 | 全仓库无实现。主计划未列硬需求,仅"开发执行计划修订版"提"高级动态水印" | 待产品确认是否纳入 |
|
||||||
|
| EXIF 清理 | 旧报告声明不存在。客户端直传路径服务端无法介入;需改为接收侧重编码 | 安全增强,建议单列 |
|
||||||
|
| 直传凭证路径无服务端内容校验 | `getUploadToken` 只生成 presigned PUT,服务端不校验类型/大小/魔数,且不落 DB 记录(客户端用返回的 fileKey 但无对应 UserPhoto 行,导致后续 `getSignedUrl` 归属校验查不到) | **契约层缺口**,超出本阶段安全 Gate 范围,建议单列 |
|
||||||
|
| 违规自动升级 | `ChatSafetyService` 注释明确 "Phase 17: risk_events 落库 + RiskAction 执行",当前 high 仅拦截不落库 | Phase 17 既定范围 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 证据
|
||||||
|
|
||||||
|
- 变更文件:`PhotoService.java`、`ChatSafetyService.java`
|
||||||
|
- 新增测试:`PhotoUploadValidationTest.java`(6 用例)
|
||||||
|
- 定向测试:`PhotoUploadValidationTest + PhotoServiceTest + ChatSafetyServiceTest` = 17/17 绿
|
||||||
|
- 负向验证:1/6 精准变红(`spoofedContentTypeRejectedByMagic`)
|
||||||
|
- 全量回归:334 / 0 / 0 / 3 skipped / BUILD SUCCESS
|
||||||
|
- 离线容器:`danyang-match-backend-test:latest`,卷 `danyang-m2-cache`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Next
|
||||||
|
|
||||||
|
Phase 11(Notification — EXTEND)需用户确认后启动。按计划每阶段出报告后停止。
|
||||||
+4
-3
@@ -1,8 +1,8 @@
|
|||||||
# 项目状态跟踪 — STATUS.md
|
# 项目状态跟踪 — STATUS.md
|
||||||
|
|
||||||
> **2026-09-16 当前有效结论:`V1.1 INCREMENTAL RECOVERY PHASE 4 COMPLETE / UI REDESIGN ACCEPTED BY USER / PHASE 08-09 RECOVERY CLOSED / GIT CLEAN`。**
|
> **2026-09-16 当前有效结论:`V1.1 INCREMENTAL RECOVERY PHASE 4 COMPLETE / UI REDESIGN ACCEPTED BY USER / PHASE 08-10 RECOVERY CLOSED / GIT CLEAN`。**
|
||||||
>
|
>
|
||||||
> 阶段 0(在途收口)、阶段 1(小程序 C 组 + discover 筛选重设计)、阶段 2(管理端 Vue 新框架适配)、阶段 3(CapabilityService 独立化)、阶段 4(双端总验收)全部完成。三端构建通过、**13/13 契约全绿**、核心 11 页面**全部 TS-only(11/11)**。后端全量回归已清偿(328 tests / 0 failures / 0 errors)。**UI 重设计首批 11 页已于 2026-09-16 通过用户验收(结论:基本达标)**;`/me/*` 前后端契约同轮收敛,17 个 404 调用已恢复。**Phase 08(Like / Match / Block)与 Phase 09(Text Chat)复验已关闭**,两轮共修复 7 处真实缺陷,并各自补齐测试保真度与守卫。**Phase 10 需用户确认后启动。**
|
> 阶段 0(在途收口)、阶段 1(小程序 C 组 + discover 筛选重设计)、阶段 2(管理端 Vue 新框架适配)、阶段 3(CapabilityService 独立化)、阶段 4(双端总验收)全部完成。三端构建通过、**13/13 契约全绿**、核心 11 页面**全部 TS-only(11/11)**。后端全量回归已清偿(334 tests / 0 failures / 0 errors)。**UI 重设计首批 11 页已于 2026-09-16 通过用户验收(结论:基本达标)**;`/me/*` 前后端契约同轮收敛,17 个 404 调用已恢复。**Phase 08(Like / Match / Block)、Phase 09(Text Chat)、Phase 10(Image & Chat Safety)复验已关闭**,三轮共修复 9 处真实缺陷,并各自补齐测试保真度与守卫。**Phase 11 需用户确认后启动。**
|
||||||
|
|
||||||
最后更新: 2026-09-16 — 分支 `main`(`feat-ui-v11` 保留为冗余锚点)
|
最后更新: 2026-09-16 — 分支 `main`(`feat-ui-v11` 保留为冗余锚点)
|
||||||
|
|
||||||
@@ -14,13 +14,14 @@
|
|||||||
| Phase 07 V1.1 首页业务与找对象 | **业务+UI 已通过用户验收**(2026-09-16) | HomeApplicationService 真实聚合;CapabilityService 独立化;discover 筛选完整重设计(5 chip + 4 section + 底部抽屉 + 滚动定位高亮);UI 按 2026-09-13 规格重新实现,11 页经用户验收通过 |
|
| Phase 07 V1.1 首页业务与找对象 | **业务+UI 已通过用户验收**(2026-09-16) | HomeApplicationService 真实聚合;CapabilityService 独立化;discover 筛选完整重设计(5 chip + 4 section + 底部抽屉 + 滚动定位高亮);UI 按 2026-09-13 规格重新实现,11 页经用户验收通过 |
|
||||||
| **Phase 08 V1.1 Like / Match / Block** | **已完成**(2026-09-16) | KEEP-VERIFY 复验。修复 3 处真实缺陷:① `DiscoverService.searchUsers` 无查看者身份 → INV-004「搜索不可见」整体失效;② 详情路径无 Block 判断;③ `RelationshipService.like()` 把 `"liked"` 当 match source 写库 → 违反 `V003 chk_match_source`(H2 测试库无该约束故长期静默)。新增 `DiscoverBlockInvisibilityIntegrationTest`(9 用例,含反向拉黑);`schema-h2.sql` 补齐生产约束与 `user_profiles`;负向验证确认守卫会红。报告:`docs/Phase-08-V11-Recovery-Completion-Report-2026-09-16.md` |
|
| **Phase 08 V1.1 Like / Match / Block** | **已完成**(2026-09-16) | KEEP-VERIFY 复验。修复 3 处真实缺陷:① `DiscoverService.searchUsers` 无查看者身份 → INV-004「搜索不可见」整体失效;② 详情路径无 Block 判断;③ `RelationshipService.like()` 把 `"liked"` 当 match source 写库 → 违反 `V003 chk_match_source`(H2 测试库无该约束故长期静默)。新增 `DiscoverBlockInvisibilityIntegrationTest`(9 用例,含反向拉黑);`schema-h2.sql` 补齐生产约束与 `user_profiles`;负向验证确认守卫会红。报告:`docs/Phase-08-V11-Recovery-Completion-Report-2026-09-16.md` |
|
||||||
| **Phase 09 V1.1 Text Chat** | **已完成**(2026-09-16) | KEEP-VERIFY 复验。修复 4 处真实缺陷:① WebSocket 下行整条链路是死代码(无握手鉴权/无 userId 写入/`sendToUser` 零调用点)→ 新增 `ChatHandshakeInterceptor` + 落库后推 `message.new`;② Unmatch 不联动会话 + 发消息只校验会话状态 → 解除后仍可发消息 → 新增 `assertMatchAlive` + `MatchController.unmatch` 联动;③ `RelationService.block` 调静态 `MatchService.unmatch`(只改内存态)→ Block 后 Match 未落库;④ 聊天四个接口无成员校验 → IDOR。新增 `ChatPhase09IntegrationTest`(11 用例)+ `ChatWebSocketHandlerTest`(7 用例);负向验证 3 处守卫精准变红。报告:`docs/Phase-09-V11-Recovery-Completion-Report-2026-09-16.md` |
|
| **Phase 09 V1.1 Text Chat** | **已完成**(2026-09-16) | KEEP-VERIFY 复验。修复 4 处真实缺陷:① WebSocket 下行整条链路是死代码(无握手鉴权/无 userId 写入/`sendToUser` 零调用点)→ 新增 `ChatHandshakeInterceptor` + 落库后推 `message.new`;② Unmatch 不联动会话 + 发消息只校验会话状态 → 解除后仍可发消息 → 新增 `assertMatchAlive` + `MatchController.unmatch` 联动;③ `RelationService.block` 调静态 `MatchService.unmatch`(只改内存态)→ Block 后 Match 未落库;④ 聊天四个接口无成员校验 → IDOR。新增 `ChatPhase09IntegrationTest`(11 用例)+ `ChatWebSocketHandlerTest`(7 用例);负向验证 3 处守卫精准变红。报告:`docs/Phase-09-V11-Recovery-Completion-Report-2026-09-16.md` |
|
||||||
|
| **Phase 10 V1.1 Image & Chat Safety** | **已完成**(2026-09-16) | KEEP-VERIFY 复验。**Gate 通过**。修复 2 处缺陷:① **D1(Critical,生产故障)** `PhotoService.uploadAndSave` 魔数校验因 `new byte[n]` 从不读取流而**恒失败** → 本地资格佐证上传 100% 失败;且 image-only MIME 在 local_eligibility 后无条件执行致 PDF 被挡 → 改为 `readNBytes` 真读取 + MIME 按 photoType 分流 + PDF 跳过图片魔数;② **D2(Low)** `ChatSafetyService` 把命中的高风险词写入 warn 日志 → 改为不记具体词汇。**推翻旧报告**「伪装恶意图片被拒(Magic Number校验)」与「EXIF清理预留(stripExif占位,符号不存在)」两声明。新增 `PhotoUploadValidationTest`(6 用例,`MockMultipartFile` 真走校验链路)+ 负向验证精准变红。报告:`docs/Phase-10-V11-Recovery-Completion-Report-2026-09-16.md` |
|
||||||
| 阶段 0 在途收口 | 已完成 | 56 个改动分类提交为 7 个 commit;Git 清零;Flyway 实际 V035 已登记 |
|
| 阶段 0 在途收口 | 已完成 | 56 个改动分类提交为 7 个 commit;Git 清零;Flyway 实际 V035 已登记 |
|
||||||
| 阶段 1 小程序 C 组 + discover | 已完成 | C 组 content/anti-fraud/service-station 迁 TS 唯一源;discover 筛选从参数到 UI 完整重写 |
|
| 阶段 1 小程序 C 组 + discover | 已完成 | C 组 content/anti-fraud/service-station 迁 TS 唯一源;discover 筛选从参数到 UI 完整重写 |
|
||||||
| 阶段 2 管理端 Vue 适配 | 已完成 | 18 modules + 22 routes + vue-tsc 全绿 + vite build 11.68s |
|
| 阶段 2 管理端 Vue 适配 | 已完成 | 18 modules + 22 routes + vue-tsc 全绿 + vite build 11.68s |
|
||||||
| 阶段 3 CapabilityService 独立化 | 已完成 | New `capability` 模块:CapabilityService(getCapabilities + 公共判断方法)+ CapabilityController(GET /api/v1/capabilities);HomeApplicationService 完全委托 |
|
| 阶段 3 CapabilityService 独立化 | 已完成 | New `capability` 模块:CapabilityService(getCapabilities + 公共判断方法)+ CapabilityController(GET /api/v1/capabilities);HomeApplicationService 完全委托 |
|
||||||
| 阶段 4 双端总验收 | **已完成**(2026-09-14) | 三端构建全绿 + 13/13 契约全绿 + 源码健康检查通过;验收报告见 docs/superpowers/plans/2026-09-14-phase4-acceptance-report.md |
|
| 阶段 4 双端总验收 | **已完成**(2026-09-14) | 三端构建全绿 + 13/13 契约全绿 + 源码健康检查通过;验收报告见 docs/superpowers/plans/2026-09-14-phase4-acceptance-report.md |
|
||||||
| Flyway 迁移 | V001~V035 | V028~V035 已纳入 STATUS |
|
| Flyway 迁移 | V001~V035 | V028~V035 已纳入 STATUS |
|
||||||
| Backend 全量回归 | **已清偿**(2026-09-16) | 离线容器(`danyang-match-backend-test`)运行:**328 tests / 0 failures / 0 errors / 3 skipped / BUILD SUCCESS**;含 `CanonicalRouteContractTest`、`ChatContractJsonTest`、`MeRouteContractTest`、`DiscoverBlockInvisibilityIntegrationTest`、`ChatPhase09IntegrationTest`、`ChatWebSocketHandlerTest` |
|
| Backend 全量回归 | **已清偿**(2026-09-16) | 离线容器(`danyang-match-backend-test`)运行:**334 tests / 0 failures / 0 errors / 3 skipped / BUILD SUCCESS**;含 `CanonicalRouteContractTest`、`ChatContractJsonTest`、`MeRouteContractTest`、`DiscoverBlockInvisibilityIntegrationTest`、`ChatPhase09IntegrationTest`、`ChatWebSocketHandlerTest`、`PhotoUploadValidationTest` |
|
||||||
| 路由/字段契约收敛 | **已完成**(2026-09-15) | 字段:会话 `target*` + `sentAt` + `relationStatus`(commit `687a156`);路径:采用 2026-09-14 canonical(`/likes` `/passes` `/blocks` `/matches`,缘分保留 `/relations/fate/*`),旧路径与 `success` 桩实现已移除;裁决由 `CanonicalRouteContractTest` 锁定 |
|
| 路由/字段契约收敛 | **已完成**(2026-09-15) | 字段:会话 `target*` + `sentAt` + `relationStatus`(commit `687a156`);路径:采用 2026-09-14 canonical(`/likes` `/passes` `/blocks` `/matches`,缘分保留 `/relations/fate/*`),旧路径与 `success` 桩实现已移除;裁决由 `CanonicalRouteContractTest` 锁定 |
|
||||||
| `/me/*` 路径族 | **已完成**(2026-09-16) | 小程序按 REST 规范(§16 / §22-§40 / §80 / §83-§84)调用 `/me/*`,后端此前**无 `/me` 基路径**致 17 个调用 404。新增 `MeController` 委托既有 service 暴露规范路径,旧路径全部保留,小程序三条历史写法(`/me/privacy-settings`、`/me/photos/{id}/main`、`/me/appeals`)一并兼容;由 `MeRouteContractTest` 锁定 |
|
| `/me/*` 路径族 | **已完成**(2026-09-16) | 小程序按 REST 规范(§16 / §22-§40 / §80 / §83-§84)调用 `/me/*`,后端此前**无 `/me` 基路径**致 17 个调用 404。新增 `MeController` 委托既有 service 暴露规范路径,旧路径全部保留,小程序三条历史写法(`/me/privacy-settings`、`/me/photos/{id}/main`、`/me/appeals`)一并兼容;由 `MeRouteContractTest` 锁定 |
|
||||||
| Admin Web | **验证通过** | 2026-09-14 阶段 4:vue-tsc ✅、vite build ✅ 11.68s;18 modules 齐全;壳层+工作台通过参考图 QA |
|
| Admin Web | **验证通过** | 2026-09-14 阶段 4:vue-tsc ✅、vite build ✅ 11.68s;18 modules 齐全;壳层+工作台通过参考图 QA |
|
||||||
|
|||||||
@@ -215,11 +215,12 @@
|
|||||||
|
|
||||||
**Files:** photo 模块、message safety、对象存储配置和聊天页面。
|
**Files:** photo 模块、message safety、对象存储配置和聊天页面。
|
||||||
|
|
||||||
- [ ] 验证图片私有访问、类型/大小校验、审核和水印。
|
- [x] 验证图片私有访问、类型/大小校验、审核和水印。
|
||||||
- [ ] 验证风险识别、举报入口和违规内容处理。
|
- [x] 验证风险识别、举报入口和违规内容处理。
|
||||||
- [ ] 检查日志不记录聊天正文、身份证、手机号或签名 URL。
|
- [x] 检查日志不记录聊天正文、身份证、手机号或签名 URL。
|
||||||
|
|
||||||
**Gate:** 越权图片访问被拒绝,敏感内容不进入普通日志。
|
**Gate:** 越权图片访问被拒绝,敏感内容不进入普通日志。
|
||||||
|
(2026-09-16 复验通过:修复 D1 图片上传魔数校验死代码——本地资格佐证上传曾 100% 失败;D2 高风险词写入普通日志。水印未实现,登记 gap。)
|
||||||
|
|
||||||
### Phase 11:Notification — EXTEND
|
### Phase 11:Notification — EXTEND
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user