#!/usr/bin/env dart // ignore_for_file: avoid_print — 手动 E2E 脚本,print 即输出。 /// M3 E2E 烟囱测试脚本(T3-21 收官):对 compose 真实后端跑通社区全链路。 /// /// 前置条件:patbond-api 目录执行 `docker compose up -d`(六容器: /// postgres + minio + auth:8081 + user:8082 + pet:8083 + community:8084) /// 运行方式:dart run test_e2e_m3_manual.dart /// /// 覆盖 14 个场景(工单 T3-21 定义的链路,冻结契约 openapi v1.3.0): /// 1. 注册账号 A、B(:8081) /// 2. A 两步上传图片:createUpload(:8082)→ 预签名 PUT 直传 MinIO → confirm ready /// 3. A 创建草稿(:8084,Idempotency-Key 必带)→ 引用 ready asset → PATCH 发布 /// 4. A 的帖在 B 的 Feed 首位可见,FeedCard 字段完整(**M3 验收标准 ①**) /// 5. 预签名 GET 取回图片字节与上传一致 /// 6. B 重复点赞幂等:3 次 PUT → likeCount 恰为 1;DELETE → 0;再 DELETE 幂等 /// (**M3 验收标准 ②**) /// 7. B 收藏 → /me/bookmarks 含该帖;取消收藏后不含 /// 8. B 评论 ×2 → A 拉列表可见;B 删自己评论成功;A 删 B 的评论被拒(仅评论作者可删) /// 9. B 关注 A + follow-stats 计数;自关注 422/42204;自取关 200 no-op /// 10. Feed 游标分页不丢不重:A 批量发 25 帖 → 双粒度全量翻页比对(**M3 验收标准 ③**) /// 11. 软删帖出 Feed:A 软删一帖 → B 的 Feed 不再含该帖,直接 GET 404/40403 /// (**M3 验收标准 ④**) /// 12. 防枚举一致性:B 访问 A 的草稿 与 随机 UUID → 响应体逐字节一致 /// 13. 埋点:POST /api/v1/events(:8082)上报 v3 社区事件 → 202 逐条 accepted /// 14. 幂等重放:同 Idempotency-Key 同 hash → 返回原帖;异 hash → 409/40905 /// /// 真机四项(device-verification.md)不在本脚本范围内,按方案 A 挂起。 library; import 'dart:convert'; import 'dart:io'; import 'dart:math'; import 'dart:typed_data'; const authUrl = 'http://127.0.0.1:8081'; // patbond-auth const userUrl = 'http://127.0.0.1:8082'; // patbond-user(/events、media 两步上传) const communityUrl = 'http://127.0.0.1:8084'; // patbond-community(社区 19 操作) /// 固定测试图:1×1 JPEG(344 字节),sha256 为下方常量。 /// complete 仅 HEAD 校验 byteSize/Content-Type,内容不核验(契约 v1.3.0), /// 但用真 JPEG 让宽高回填与预签名 GET 字节比对都走真路径。 const testJpegBase64 = '/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRof' 'Gh0aHBwcJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPDIzNP/AABEIAAEAAQMBIgACEQEDEQH/' 'xAAfAAABBQEBAQEBAQAAAAAAAAABAgMEBQYHCAkKC//EALUQAAIBAwMCBAMFBQQEAAABfQEC' 'AwAEEQUSITFBBhNRYQcicRQygZGhCCNCscEVUtHwJDNicoIJChYXGBkaJSYnKCkqNDU2Nzg5' 'OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6g4SFhoeIiYqSk5SVlpeYmZqio6Sl' 'pqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2drh4uPk5ebn6Onq8fLz9PX29/j5+v/a' 'AAwDAQACEQMRAD8A9/ooooA//9k='; /// 上方常量解码后的 sha256(`sha256sum` 实测)——契约照收照存,不做内容核验。 const testJpegSha256 = '32142d9c5636535cf882b05c03275d2b58150623df750b059db0bbdab6973ce8'; /// 场景 10 批量发帖数(验收标准 ③ 要求 25+)。 const bulkPostCount = 25; final client = HttpClient(); int _passed = 0; /// token 脱敏:仅留前 20 字符。 String redact(String token) => '${token.substring(0, min(20, token.length))}...'; /// 预签名 URL 脱敏:保留 host + 对象路径,签名 query 整体抹掉。 String redactSignedUrl(String url) { final uri = Uri.parse(url); return '${uri.scheme}://${uri.host}:${uri.port}${uri.path}' '?'; } void fail(String msg) { print(' ✗ $msg'); client.close(); exit(1); } void check(bool cond, String okMsg, String failMsg) { if (cond) { print(' ✓ $okMsg'); } else { fail(failMsg); } } class Resp { final int status; final String body; final Map json; Resp(this.status, this.body, this.json); Map get data => json['data'] as Map; int? get code => json['code'] as int?; } Future call( String method, String url, { String? token, Object? body, Map? headers, }) async { final req = await client.openUrl(method, Uri.parse(url)); if (body != null) req.headers.contentType = ContentType.json; if (token != null) req.headers.set('Authorization', 'Bearer $token'); headers?.forEach(req.headers.set); if (body != null) req.write(jsonEncode(body)); final resp = await req.close(); final text = await utf8.decodeStream(resp); Map parsed = const {}; try { parsed = jsonDecode(text) as Map; } catch (_) { // 非 JSON 响应,parsed 留空 map,由调用方按 status 断言 } return Resp(resp.statusCode, text, parsed); } /// 预签名 PUT 直传:不带 Bearer(鉴权即 URL 签名本身),requiredHeaders 原样携带, /// contentLength 显式设置(与 lib/features/community/media_direct_upload.dart 同构)。 Future presignedPut( String url, Map requiredHeaders, Uint8List bytes, ) async { final req = await client.openUrl('PUT', Uri.parse(url)); requiredHeaders.forEach(req.headers.set); req.contentLength = bytes.length; req.add(bytes); final resp = await req.close(); await resp.drain(); return resp.statusCode; } /// 预签名 GET:取回对象原始字节(桶私有,无签名直访被拒)。 Future<(int, Uint8List)> presignedGet(String url) async { final req = await client.openUrl('GET', Uri.parse(url)); final resp = await req.close(); final chunks = []; await resp.forEach(chunks.addAll); return (resp.statusCode, Uint8List.fromList(chunks)); } String uuidV4() { final rnd = Random.secure(); final bytes = List.generate(16, (_) => rnd.nextInt(256)); bytes[6] = (bytes[6] & 0x0f) | 0x40; bytes[8] = (bytes[8] & 0x3f) | 0x80; final h = bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); return '${h.substring(0, 8)}-${h.substring(8, 12)}-${h.substring(12, 16)}-' '${h.substring(16, 20)}-${h.substring(20)}'; } /// 全量翻页 Feed:返回 (有序 id 列表, 页数)。 /// 页上限兜底防死循环(游标语义破裂时立即暴露而非挂死)。 Future<(List, int)> drainFeed(String token, int limit) async { final ids = []; String? cursor; var pages = 0; while (true) { final url = '$communityUrl/api/v1/feed?limit=$limit' '${cursor == null ? '' : '&cursor=${Uri.encodeQueryComponent(cursor)}'}'; final r = await call('GET', url, token: token); if (r.status != 200) fail('Feed 翻页失败(第 ${pages + 1} 页): ${r.body}'); pages++; final page = r.data; for (final item in page['items'] as List) { ids.add((item as Map)['id'] as String); } final hasMore = page['hasMore'] as bool; cursor = page['nextCursor'] as String?; if (!hasMore) { if (cursor != null) fail('末页 nextCursor 非 null: $cursor'); break; } if (cursor == null) fail('hasMore=true 但 nextCursor 为 null'); if (pages > 500) fail('翻页超过 500 页,疑似游标不推进'); } return (ids, pages); } /// 创建帖子(Idempotency-Key 必带)。 Future createPost( String token, Map body, { String? idempotencyKey, }) => call( 'POST', '$communityUrl/api/v1/posts', token: token, body: body, headers: {'Idempotency-Key': idempotencyKey ?? uuidV4()}, ); void main() async { final ts = DateTime.now().millisecondsSinceEpoch; final usernameA = 'e2e_m3_a_$ts'; final usernameB = 'e2e_m3_b_$ts'; const password = 'Test@123456'; final phoneA = '+8613${Random().nextInt(900000000) + 100000000}'; final phoneB = '+8613${Random().nextInt(900000000) + 100000000}'; final imageBytes = base64Decode(testJpegBase64); print('=== Patbond M3 E2E 烟囱测试开始(T3-21 收官)==='); print('账号 A: $usernameA'); print('账号 B: $usernameB'); print('测试图: ${imageBytes.length} 字节 image/jpeg,sha256=$testJpegSha256'); print(''); try { // ================================================================ // [1/14] 注册账号 A、B(:8081) // ================================================================ print('[1/14] 注册账号 A、B(:8081)'); var r = await call( 'POST', '$authUrl/api/v1/auth/register', body: {'username': usernameA, 'phone': phoneA, 'password': password}, ); print(' POST /api/v1/auth/register (A) → ${r.status}'); check(r.status == 200 && r.code == 0, 'A 注册成功', 'A 注册失败: ${r.body}'); final userIdA = r.data['userId'] as String; final tokenA = r.data['accessToken'] as String; print(' userId(A): $userIdA'); print(' accessToken(A): ${redact(tokenA)}'); r = await call( 'POST', '$authUrl/api/v1/auth/register', body: {'username': usernameB, 'phone': phoneB, 'password': password}, ); print(' POST /api/v1/auth/register (B) → ${r.status}'); check(r.status == 200 && r.code == 0, 'B 注册成功', 'B 注册失败: ${r.body}'); final userIdB = r.data['userId'] as String; final tokenB = r.data['accessToken'] as String; print(' userId(B): $userIdB'); print(' accessToken(B): ${redact(tokenB)}'); check(userIdA != userIdB, 'A/B 为两个独立账号(模拟两客户端)', 'A/B userId 相同'); _passed++; print(''); // ================================================================ // [2/14] 两步上传:createUpload → 预签名 PUT 直传 MinIO → confirm ready // ================================================================ print('[2/14] A 两步上传图片:createUpload(:8082)→ 预签名 PUT → confirm'); r = await call( 'POST', '$userUrl/api/v1/media/uploads', token: tokenA, body: { 'kind': 'image', 'purpose': 'post_image', 'mimeType': 'image/jpeg', 'byteSize': imageBytes.length, 'sha256': testJpegSha256, }, ); print(' POST /api/v1/media/uploads → ${r.status}'); check( r.status == 201 && r.code == 0, 'asset 登记成功(201),返回预签名直传凭据', 'createUpload 失败: ${r.status} ${r.body}', ); final creds = r.data; final assetId = creds['assetId'] as String; final uploadUrl = creds['uploadUrl'] as String; final requiredHeaders = (creds['requiredHeaders'] as Map).map( (k, v) => MapEntry(k as String, v as String), ); print(' assetId: $assetId'); print(' uploadUrl: ${redactSignedUrl(uploadUrl)}'); print(' method: ${creds['method']} / expiresAt: ${creds['expiresAt']}'); print(' requiredHeaders: $requiredHeaders'); check( creds['method'] == 'PUT' && requiredHeaders.length == 1 && requiredHeaders['Content-Type'] == 'image/jpeg', 'requiredHeaders 恒且仅一键 {Content-Type: image/jpeg}', 'requiredHeaders 形态不符: $requiredHeaders', ); check( Uri.parse(uploadUrl).queryParameters.containsKey('X-Amz-Signature'), '预签名 URL 携带 SigV4 query 签名族(直传不经应用服务器)', 'uploadUrl 无 X-Amz-Signature', ); final putStatus = await presignedPut( uploadUrl, requiredHeaders, imageBytes, ); print( ' PUT (${imageBytes.length} 字节,原样携带 requiredHeaders)' ' → $putStatus', ); check(putStatus == 200, '直传 MinIO 成功(存储侧接受签名)', '直传失败: HTTP $putStatus'); r = await call( 'POST', '$userUrl/api/v1/media/uploads/$assetId/complete', token: tokenA, ); print(' POST /api/v1/media/uploads/$assetId/complete → ${r.status}'); final asset = r.data; check( r.status == 200 && asset['status'] == 'ready' && asset['readyAt'] != null && asset['url'] != null && asset['byteSize'] == imageBytes.length, 'uploading→ready(byteSize=${asset['byteSize']},' 'widthPx=${asset['widthPx']} heightPx=${asset['heightPx']},' 'readyAt 已写,url 现签非空)', 'confirm 不符: ${r.status} ${r.body}', ); print(' asset.url: ${redactSignedUrl(asset['url'] as String)}'); r = await call( 'POST', '$userUrl/api/v1/media/uploads/$assetId/complete', token: tokenA, ); print(' POST .../complete(重复确认)→ ${r.status}'); check( r.status == 200 && r.data['id'] == assetId && r.data['status'] == 'ready', '已 ready 重复 complete 幂等 200 同一 asset(现签新 GET URL)', '重复 complete 不幂等: ${r.status} ${r.body}', ); _passed++; print(''); // ================================================================ // [3/14] A 创建草稿(Idempotency-Key 必带)→ 引用 ready asset → PATCH 发布 // ================================================================ print('[3/14] A 创建草稿(:8084,引用 ready asset)→ PATCH 发布'); const postTitle = 'M3 烟囱主贴'; final postContent = 'M3 收官烟囱:两步上传 + 草稿发布 + 互动全链路。ts=$ts'; final mainKey = uuidV4(); r = await createPost(tokenA, { 'title': postTitle, 'content': postContent, 'category': 'general', 'status': 'draft', 'media': [ {'assetId': assetId, 'caption': '烟囱测试图'}, ], }, idempotencyKey: mainKey); print( ' POST /api/v1/posts (status=draft, Idempotency-Key 已带) ' '→ ${r.status}', ); check( r.status == 201 && r.code == 0, '草稿创建成功(201)', '草稿创建失败: ${r.status} ${r.body}', ); final draftPost = r.data; final postId = draftPost['id'] as String; final draftVersion = draftPost['version'] as int; print( ' postId: $postId / status: ${draftPost['status']} / ' 'version: $draftVersion / publishedAt: ${draftPost['publishedAt']}', ); check( draftPost['status'] == 'draft' && draftPost['publishedAt'] == null, '草稿态 status=draft 且 publishedAt=null(发布时才恰写一次)', '草稿态字段不符: ${r.body}', ); final draftMedia = draftPost['media'] as List; check( draftMedia.length == 1 && (draftMedia.first as Map)['assetId'] == assetId && (draftMedia.first as Map)['position'] == 0 && (draftMedia.first as Map)['isCover'] == true, 'media 挂接 1 图:position=0(按数组序)、isCover 由服务端置真(库内恒有唯一封面)', 'media 挂接不符: $draftMedia', ); r = await call( 'PATCH', '$communityUrl/api/v1/posts/$postId', token: tokenA, body: {'version': draftVersion, 'status': 'published'}, ); print( ' PATCH /api/v1/posts/$postId (draft→published, ' 'version=$draftVersion) → ${r.status}', ); final published = r.data; check( r.status == 200 && published['status'] == 'published' && published['publishedAt'] != null && published['version'] == draftVersion + 1, '发布成功:status=published,publishedAt 已写,' 'version $draftVersion→${published['version']}', '发布不符: ${r.status} ${r.body}', ); print(' publishedAt: ${published['publishedAt']}'); final publishedMediaUrl = ((published['media'] as List).first as Map)['url'] as String; print(' media[0].url: ${redactSignedUrl(publishedMediaUrl)}'); _passed++; print(''); // ================================================================ // [4/14] 验收标准 ①:A 的帖在 B 的 Feed 可见(另一客户端) // ================================================================ print('[4/14] 【验收①】B 拉 /api/v1/feed → A 的帖首位可见,FeedCard 字段完整'); r = await call('GET', '$communityUrl/api/v1/feed?limit=5', token: tokenB); print(' GET /api/v1/feed?limit=5 (B 的 token) → ${r.status}'); check(r.status == 200 && r.code == 0, 'Feed 返回 200', 'Feed 失败: ${r.body}'); final feedItems = r.data['items'] as List; check( feedItems.isNotEmpty && (feedItems.first as Map)['id'] == postId, 'A 刚发布的帖在 B 的 Feed 首位(published_at DESC)', 'Feed 首位非该帖: ${feedItems.isEmpty ? '空' : (feedItems.first as Map)['id']}', ); final card = feedItems.first as Map; final cardAuthor = card['author'] as Map; print( ' FeedCard: title=${card['title']} / mediaCount=${card['mediaCount']}' ' / like=${card['likeCount']} comment=${card['commentCount']}' ' bookmark=${card['bookmarkCount']}', ); print( ' author: userId=${cardAuthor['userId']} ' 'nickname=${cardAuthor['nickname']}', ); check( cardAuthor['userId'] == userIdA && cardAuthor.containsKey('nickname'), 'AuthorSummary 归因 A 且 nickname 键在(空昵称已由服务端回退 username)', 'AuthorSummary 不符: $cardAuthor', ); check( !cardAuthor.containsKey('bio') && !cardAuthor.containsKey('username'), 'AuthorSummary 不露 bio / username', 'AuthorSummary 泄露隐私字段: ${cardAuthor.keys}', ); check( card['title'] == postTitle && card['contentPreview'] == postContent && card['category'] == 'general' && card['mediaCount'] == 1 && card['likeCount'] == 0 && card['commentCount'] == 0 && card['bookmarkCount'] == 0 && card['likedByMe'] == false && card['bookmarkedByMe'] == false && card['publishedAt'] != null, 'FeedCard 必填齐备:contentPreview 原样透传(<200 码点)、mediaCount=1、' '三计数为 0、B 视角 likedByMe/bookmarkedByMe=false、publishedAt 非空', 'FeedCard 字段不符: ${jsonEncode(card)}', ); final coverImage = card['coverImage'] as Map?; check( coverImage != null && coverImage['assetId'] == assetId && coverImage['isCover'] == true && (coverImage['url'] as String).isNotEmpty, 'coverImage = 唯一 is_cover 行(assetId 命中,url 现签非空)', 'coverImage 不符: $coverImage', ); check( !card.containsKey('content') && !card.containsKey('media') && !card.containsKey('version'), 'FeedCard 裁剪生效:不带 content 全文 / media 整组 / version', 'FeedCard 未按契约裁剪: ${card.keys}', ); _passed++; print(''); // ================================================================ // [5/14] 预签名 GET 取回图片字节与上传一致 // ================================================================ print('[5/14] 预签名 GET 取回图片字节 → 与上传字节逐字节比对'); final coverUrl = (coverImage!['url'] as String); print(' GET ${redactSignedUrl(coverUrl)}'); final (getStatus, downloaded) = await presignedGet(coverUrl); print(' GET → $getStatus(${downloaded.length} 字节)'); check(getStatus == 200, '预签名 GET 取回 200', '预签名 GET 失败: HTTP $getStatus'); check( downloaded.length == imageBytes.length && const ListEquality().equals(downloaded, imageBytes), '取回 ${downloaded.length} 字节与上传逐字节一致(内容往返无损)', '字节不一致:上传 ${imageBytes.length} / 取回 ${downloaded.length}', ); final unsignedUrl = Uri.parse(coverUrl).replace(query: '').toString(); final (unsignedStatus, _) = await presignedGet(unsignedUrl); print(' GET <同一对象但去掉签名> → $unsignedStatus'); check( unsignedStatus == 403, '桶保持私有:无签名直访被存储侧拒绝(403)', '无签名直访未被拒: HTTP $unsignedStatus', ); _passed++; print(''); // ================================================================ // [6/14] 验收标准 ②:B 重复点赞不重复计数 // ================================================================ print( '[6/14] 【验收②】B 连续 3 次 PUT like → likeCount 恰为 1;' 'DELETE → 0;再 DELETE 幂等', ); for (var i = 1; i <= 3; i++) { r = await call( 'PUT', '$communityUrl/api/v1/posts/$postId/like', token: tokenB, ); print( ' PUT /like(第 $i 次)→ ${r.status} ' '${jsonEncode(r.data)}', ); check( r.status == 200 && r.data['liked'] == true && r.data['likeCount'] == 1, '第 $i 次返回权威终态 {liked:true, likeCount:1}(非 409)', '第 $i 次点赞终态不符: ${r.status} ${r.body}', ); } r = await call('GET', '$communityUrl/api/v1/posts/$postId', token: tokenB); check( r.status == 200 && r.data['likeCount'] == 1 && r.data['likedByMe'] == true, '详情读回 likeCount=1(3 次 PUT 仅实际插入一次才 +1)', '详情 likeCount 不符: ${r.body}', ); r = await call( 'DELETE', '$communityUrl/api/v1/posts/$postId/like', token: tokenB, ); print(' DELETE /like → ${r.status} ${jsonEncode(r.data)}'); check( r.status == 200 && r.data['liked'] == false && r.data['likeCount'] == 0, '取消点赞返回 {liked:false, likeCount:0}', '取消点赞不符: ${r.status} ${r.body}', ); r = await call( 'DELETE', '$communityUrl/api/v1/posts/$postId/like', token: tokenB, ); print(' DELETE /like(重复)→ ${r.status} ${jsonEncode(r.data)}'); check( r.status == 200 && r.data['liked'] == false && r.data['likeCount'] == 0, '取消不存在的点赞不报错不减计数(DELETE 语义幂等)', '重复取消不幂等: ${r.status} ${r.body}', ); // 恢复一个点赞,供后续 Feed 卡片计数观察。 r = await call( 'PUT', '$communityUrl/api/v1/posts/$postId/like', token: tokenB, ); check( r.status == 200 && r.data['likeCount'] == 1, '再次点赞恢复 likeCount=1(供后续卡片计数观察)', '恢复点赞失败: ${r.body}', ); _passed++; print(''); // ================================================================ // [7/14] B 收藏 + /me/bookmarks 含该帖;取消收藏后不含 // ================================================================ print('[7/14] B 收藏 → GET /api/v1/me/bookmarks 含该帖;取消收藏后不含'); r = await call( 'PUT', '$communityUrl/api/v1/posts/$postId/bookmark', token: tokenB, ); print(' PUT /bookmark → ${r.status} ${jsonEncode(r.data)}'); check( r.status == 200 && r.data['bookmarked'] == true && r.data['bookmarkCount'] == 1, '收藏返回权威终态 {bookmarked:true, bookmarkCount:1}', '收藏不符: ${r.status} ${r.body}', ); r = await call( 'GET', '$communityUrl/api/v1/me/bookmarks?limit=20', token: tokenB, ); print(' GET /api/v1/me/bookmarks → ${r.status}'); var bookmarkIds = (r.data['items'] as List) .map((e) => (e as Map)['id'] as String) .toList(); check( r.status == 200 && bookmarkIds.contains(postId), '收藏列表含该帖(共 ${bookmarkIds.length} 条,项形态 = FeedCard)', '收藏列表不含该帖: $bookmarkIds', ); final bookmarkCard = (r.data['items'] as List).firstWhere((e) => (e as Map)['id'] == postId) as Map; check( bookmarkCard['bookmarkedByMe'] == true && bookmarkCard['likedByMe'] == true && bookmarkCard['publishedAt'] != null, '收藏项 bookmarkedByMe/likedByMe 为 B 视角,publishedAt 恒非空', '收藏项视角字段不符: ${jsonEncode(bookmarkCard)}', ); r = await call( 'DELETE', '$communityUrl/api/v1/posts/$postId/bookmark', token: tokenB, ); print(' DELETE /bookmark → ${r.status} ${jsonEncode(r.data)}'); check( r.status == 200 && r.data['bookmarked'] == false && r.data['bookmarkCount'] == 0, '取消收藏返回 {bookmarked:false, bookmarkCount:0}', '取消收藏不符: ${r.status} ${r.body}', ); r = await call( 'GET', '$communityUrl/api/v1/me/bookmarks?limit=20', token: tokenB, ); bookmarkIds = (r.data['items'] as List) .map((e) => (e as Map)['id'] as String) .toList(); check( r.status == 200 && !bookmarkIds.contains(postId), '取消收藏后列表不含该帖(剩 ${bookmarkIds.length} 条)', '取消收藏后仍在列表: $bookmarkIds', ); _passed++; print(''); // ================================================================ // [8/14] B 评论 → A 可见;B 删自己评论成功;A 删 B 的评论被拒 // ================================================================ print( '[8/14] B 评论 ×2 → A 拉列表可见;B 删自己评论成功;' 'A(帖主)删 B 的评论被拒', ); r = await call( 'POST', '$communityUrl/api/v1/posts/$postId/comments', token: tokenB, body: {'content': 'B 的第一条评论(将被 B 自己删除)'}, headers: {'Idempotency-Key': uuidV4()}, ); print(' POST /comments (B, #1) → ${r.status}'); check( r.status == 201 && r.code == 0, 'B 评论 #1 创建成功', 'B 评论 #1 失败: ${r.body}', ); final commentId1 = r.data['id'] as String; check( (r.data['author'] as Map)['userId'] == userIdB && r.data['postId'] == postId && r.data['replyToUser'] == null && !r.data.containsKey('updatedAt'), '评论作者归因 B、postId 一致、非回复 replyToUser=null、无 updatedAt(M3 无编辑)', '评论 #1 字段不符: ${r.body}', ); r = await call( 'POST', '$communityUrl/api/v1/posts/$postId/comments', token: tokenB, body: { 'content': 'B 的第二条评论(@A 回复,留作 A 越权删除测试)', 'replyToUserId': userIdA, }, headers: {'Idempotency-Key': uuidV4()}, ); print(' POST /comments (B, #2, replyToUserId=A) → ${r.status}'); check( r.status == 201 && r.code == 0, 'B 评论 #2 创建成功(@ 回复)', 'B 评论 #2 失败: ${r.body}', ); final commentId2 = r.data['id'] as String; check( (r.data['replyToUser'] as Map?)?['userId'] == userIdA, '@ 回复目标解出 AuthorSummary(单层平铺,无 parentCommentId)', 'replyToUser 不符: ${r.body}', ); r = await call( 'GET', '$communityUrl/api/v1/posts/$postId/comments?limit=20', token: tokenA, ); print(' GET /comments (A 的 token) → ${r.status}'); var commentIds = (r.data['items'] as List) .map((e) => (e as Map)['id'] as String) .toList(); check( r.status == 200 && commentIds.length == 2 && commentIds.first == commentId2 && commentIds.contains(commentId1), 'A 拉评论列表可见 B 的两条(created_at DESC,#2 在前)', 'A 侧评论列表不符: $commentIds', ); r = await call('GET', '$communityUrl/api/v1/posts/$postId', token: tokenA); check( r.data['commentCount'] == 2, 'commentCount 同事务 +1 累计为 2', 'commentCount 不符: ${r.data['commentCount']}', ); r = await call( 'DELETE', '$communityUrl/api/v1/comments/$commentId1', token: tokenB, ); print(' DELETE /comments/$commentId1 (B 删自己的) → ${r.status}'); check( r.status == 200 && r.code == 0, 'B 删自己的评论成功(软删 status→deleted)', 'B 删自己评论失败: ${r.status} ${r.body}', ); r = await call( 'DELETE', '$communityUrl/api/v1/comments/$commentId2', token: tokenA, ); print( ' DELETE /comments/$commentId2 (A 删 B 的) → ${r.status} / ' 'code ${r.code}', ); check( r.status == 403 && r.code == 40301, 'A(帖主)删 B 的评论被拒 403/40301(${r.json['message']})——仅评论作者可删(D3-7 拍板)', '越权删评论未被拒: ${r.status} ${r.body}', ); r = await call( 'GET', '$communityUrl/api/v1/posts/$postId/comments?limit=20', token: tokenA, ); commentIds = (r.data['items'] as List) .map((e) => (e as Map)['id'] as String) .toList(); check( commentIds.length == 1 && commentIds.single == commentId2, '删后列表仅剩 #2(仅 visible 评论),越权目标未被删除', '删后列表不符: $commentIds', ); r = await call('GET', '$communityUrl/api/v1/posts/$postId', token: tokenA); check( r.data['commentCount'] == 1, 'commentCount 同事务 -1 回到 1', 'commentCount 不符: ${r.data['commentCount']}', ); _passed++; print(''); // ================================================================ // [9/14] B 关注 A + follow-stats;自关注 42204;自取关 200 no-op // ================================================================ print('[9/14] B 关注 A(PUT)+ follow-stats 计数;自关注 42204;自取关 200 no-op'); r = await call( 'PUT', '$communityUrl/api/v1/users/$userIdA/follow', token: tokenB, ); print(' PUT /users/{A}/follow (B) → ${r.status} ${jsonEncode(r.data)}'); check( r.status == 200 && r.data['following'] == true && r.data['followerCount'] == 1, '关注成功:{following:true, followerCount:1}(A 的粉丝数)', '关注不符: ${r.status} ${r.body}', ); r = await call( 'PUT', '$communityUrl/api/v1/users/$userIdA/follow', token: tokenB, ); print(' PUT /users{A}/follow(重复)→ ${r.status} ${jsonEncode(r.data)}'); check( r.status == 200 && r.data['followerCount'] == 1, '重复关注幂等 200,粉丝数仍为 1(主键 (follower,followee) 即幂等键)', '重复关注不幂等: ${r.status} ${r.body}', ); r = await call( 'GET', '$communityUrl/api/v1/users/$userIdA/follow-stats', token: tokenB, ); print( ' GET /users/{A}/follow-stats (B 视角) → ${r.status} ' '${jsonEncode(r.data)}', ); check( r.status == 200 && r.data['followerCount'] == 1 && r.data['followingCount'] == 0 && r.data['followedByMe'] == true, 'A 的计数:粉丝 1 / 关注 0,followedByMe=true(B 视角)', 'follow-stats(B 视角) 不符: ${r.body}', ); r = await call( 'GET', '$communityUrl/api/v1/users/$userIdA/follow-stats', token: tokenA, ); print( ' GET /users/{A}/follow-stats (A 查自己) → ${r.status} ' '${jsonEncode(r.data)}', ); check( r.status == 200 && r.data['followerCount'] == 1 && r.data['followedByMe'] == false, 'A 查自己 followedByMe 恒 false(计数一致)', 'follow-stats(自查) 不符: ${r.body}', ); r = await call( 'GET', '$communityUrl/api/v1/users/$userIdB/follow-stats', token: tokenB, ); check( r.status == 200 && r.data['followingCount'] == 1 && r.data['followerCount'] == 0, 'B 的计数:关注 1 / 粉丝 0(实时 COUNT,无冗余计数列)', 'follow-stats(B) 不符: ${r.body}', ); r = await call( 'PUT', '$communityUrl/api/v1/users/$userIdB/follow', token: tokenB, ); print(' PUT /users/{B}/follow (B 自关注) → ${r.status} / code ${r.code}'); check( r.status == 422 && r.code == 42204, '自关注被拒 422/42204(${r.json['message']})——库层 ck_user_follows_self 兜底', '自关注未被拒: ${r.status} ${r.body}', ); r = await call( 'DELETE', '$communityUrl/api/v1/users/$userIdB/follow', token: tokenB, ); print( ' DELETE /users/{B}/follow (B 自取关) → ${r.status} ' '${jsonEncode(r.data)}', ); check( r.status == 200 && r.data['following'] == false, '自取关 200 幂等 no-op(关系行不可能存在,权威 false 即事实;42204 只在 PUT)', '自取关不符: ${r.status} ${r.body}', ); _passed++; print(''); // ================================================================ // [10/14] 验收标准 ③:Feed 游标分页不丢不重 // ================================================================ print('[10/14] 【验收③】A 批量发 $bulkPostCount 帖 → 双粒度全量翻页比对'); final bulkIds = []; for (var i = 1; i <= bulkPostCount; i++) { r = await createPost(tokenA, { 'title': 'M3 分页帖 #$i', 'content': '分页取证第 $i 帖(ts=$ts)', 'category': i.isEven ? 'help' : 'general', 'status': 'published', }); if (r.status != 201) fail('第 $i 帖创建失败: ${r.status} ${r.body}'); bulkIds.add(r.data['id'] as String); } print(' POST /api/v1/posts ×$bulkPostCount (status=published) → 全部 201'); check( bulkIds.toSet().length == bulkPostCount, '$bulkPostCount 帖全部创建成功且 id 互不相同', 'id 有重复: ${bulkIds.length} vs ${bulkIds.toSet().length}', ); final (idsSmall, pagesSmall) = await drainFeed(tokenB, 7); print(' 逐页翻到底(limit=7)→ $pagesSmall 页,共 ${idsSmall.length} 条'); check( idsSmall.toSet().length == idsSmall.length, '细粒度翻页零重复(${idsSmall.length} 条全唯一)', '细粒度翻页出现重复 id:' '${idsSmall.length - idsSmall.toSet().length} 个', ); check( pagesSmall >= 4, '翻页确实跨多页($pagesSmall 页 > 1,游标真被使用)', '页数过少无法证明翻页: $pagesSmall', ); final (idsLarge, pagesLarge) = await drainFeed(tokenB, 100); print(' 逐页翻到底(limit=100)→ $pagesLarge 页,共 ${idsLarge.length} 条'); check( idsLarge.toSet().length == idsLarge.length, '粗粒度翻页零重复(${idsLarge.length} 条全唯一)', '粗粒度翻页出现重复 id', ); check( idsSmall.length == idsLarge.length && const ListEquality().equals(idsSmall, idsLarge), '两种页大小全量结果**逐位一致**(顺序与集合都相同)→ 翻页不丢不重', '两种粒度结果不一致:limit=7 得 ${idsSmall.length} 条 / ' 'limit=100 得 ${idsLarge.length} 条;' '差集 ${idsSmall.toSet().difference(idsLarge.toSet()).length}/' '${idsLarge.toSet().difference(idsSmall.toSet()).length}', ); final missing = bulkIds.where((id) => !idsSmall.contains(id)).toList(); check( missing.isEmpty, '$bulkPostCount 帖 + 主贴全部恰好出现一次(无遗漏)', '遗漏 ${missing.length} 帖: $missing', ); check(idsSmall.contains(postId), '主贴(场景 3 发布)亦在全量结果内', '主贴不在 Feed 全量结果内'); print( ' Feed 全量条数(含既有数据):${idsSmall.length};' '本轮新增 ${bulkPostCount + 1} 条', ); _passed++; print(''); // ================================================================ // [11/14] 验收标准 ④:软删帖不再出现在公共 Feed // ================================================================ print('[11/14] 【验收④】A 软删一帖 → B 的 Feed 不再含该帖,直接 GET 404/40403'); r = await createPost(tokenA, { 'title': 'M3 待删帖', 'content': '本帖将被软删,用于验收标准 ④(ts=$ts)', 'status': 'published', }); check(r.status == 201, '待删帖创建并发布成功(201)', '待删帖创建失败: ${r.body}'); final doomedId = r.data['id'] as String; print(' doomedPostId: $doomedId'); final (idsBefore, _) = await drainFeed(tokenB, 50); check( idsBefore.first == doomedId, '删除前:该帖在 B 的 Feed 首位(全量 ${idsBefore.length} 条)', '删除前该帖不在 Feed 首位: ${idsBefore.first}', ); r = await call( 'DELETE', '$communityUrl/api/v1/posts/$doomedId', token: tokenA, ); print(' DELETE /api/v1/posts/$doomedId (A 软删) → ${r.status}'); check( r.status == 200 && r.code == 0, '软删成功(deleted_at 写入)', '软删失败: ${r.body}', ); final (idsAfter, _) = await drainFeed(tokenB, 50); print(' B 全量翻 Feed(删除后)→ ${idsAfter.length} 条'); check( !idsAfter.contains(doomedId) && idsAfter.length == idsBefore.length - 1, '删除后 B 的 Feed 全量不含该帖,且总数恰少 1(其余帖不受影响)', '软删帖仍在 Feed 或总数异常:' 'before ${idsBefore.length} / after ${idsAfter.length}', ); r = await call( 'GET', '$communityUrl/api/v1/posts/$doomedId', token: tokenB, ); print( ' GET /api/v1/posts/$doomedId (B 直接访问) → ${r.status} / ' 'code ${r.code}', ); check( r.status == 404 && r.code == 40403, 'B 直接 GET 已删帖 404/40403(${r.json['message']})', '已删帖仍可读: ${r.status} ${r.body}', ); final deletedBodyByB = r.body; r = await call( 'GET', '$communityUrl/api/v1/posts/$doomedId', token: tokenA, ); check( r.status == 404 && r.code == 40403 && r.body == deletedBodyByB, '作者 A 自己 GET 已删帖同样 404/40403(响应体与 B 逐字节一致)', '作者侧已删帖响应不一致: ${r.status} ${r.body}', ); r = await call( 'DELETE', '$communityUrl/api/v1/posts/$doomedId', token: tokenA, ); check( r.status == 404 && r.code == 40403, '重复删除与删不存在的帖同响应 404/40403(防枚举合并)', '重复删除响应不符: ${r.status} ${r.body}', ); r = await call( 'PUT', '$communityUrl/api/v1/posts/$doomedId/like', token: tokenB, ); check( r.status == 404 && r.code == 40403, '已删帖的互动面同样 404/40403(删除后一切路径关闭)', '已删帖仍可点赞: ${r.status} ${r.body}', ); _passed++; print(''); // ================================================================ // [12/14] 防枚举一致性:B 访问 A 的草稿 vs 随机 UUID // ================================================================ print('[12/14] 防枚举:B 访问 A 的草稿 与 访问随机 UUID → 响应体逐字节一致'); r = await createPost(tokenA, { 'title': 'M3 私密草稿', 'content': '仅作者可见的草稿,用于防枚举核对(ts=$ts)', 'status': 'draft', }); check( r.status == 201 && r.data['status'] == 'draft', 'A 的草稿创建成功', '草稿创建失败: ${r.body}', ); final secretDraftId = r.data['id'] as String; final randomId = uuidV4(); print(' A 的草稿 id: $secretDraftId'); print(' 随机 UUID: $randomId'); final probeRoutes = { '详情 GET /posts/{草稿}': '$communityUrl/api/v1/posts/$secretDraftId', '详情 GET /posts/{随机}': '$communityUrl/api/v1/posts/$randomId', '评论 GET /posts/{草稿}/comments': '$communityUrl/api/v1/posts/$secretDraftId/comments', '评论 GET /posts/{随机}/comments': '$communityUrl/api/v1/posts/$randomId/comments', }; final probeBodies = []; for (final entry in probeRoutes.entries) { r = await call('GET', entry.value, token: tokenB); print(' ${entry.key} → ${r.status} / code ${r.code}'); check( r.status == 404 && r.code == 40403, '404/40403(${entry.key})', '${entry.key} 未按防枚举拒绝: ${r.status} ${r.body}', ); probeBodies.add(r.body); } check( probeBodies.toSet().length == 1, '四路响应体完全一致(防枚举):${probeBodies.first}', '响应体不一致,可区分真实草稿与随机 UUID: $probeBodies', ); r = await call( 'PUT', '$communityUrl/api/v1/posts/$secretDraftId/like', token: tokenB, ); check( r.status == 404 && r.code == 40403 && r.body == probeBodies.first, '互动面 = 帖子公开面:草稿点赞同一 404/40403 响应体', '草稿互动响应不符: ${r.status} ${r.body}', ); r = await call( 'PUT', '$communityUrl/api/v1/posts/$secretDraftId/like', token: tokenA, ); check( r.status == 404 && r.code == 40403, '**作者本人**对自己草稿的互动亦 404/40403(互动面恒为公开面)', '作者可对自己草稿互动: ${r.status} ${r.body}', ); r = await call( 'GET', '$communityUrl/api/v1/posts/$secretDraftId', token: tokenA, ); check( r.status == 200 && r.data['status'] == 'draft', '草稿对作者本人详情仍可见(draft 仅作者可见)', '作者读不到自己草稿: ${r.status} ${r.body}', ); r = await call( 'GET', '$communityUrl/api/v1/me/posts?status=draft&limit=20', token: tokenA, ); check( r.status == 200 && (r.data['items'] as List).any( (e) => (e as Map)['id'] == secretDraftId, ), '/me/posts?status=draft 含该草稿(作者视角)', '我的草稿列表不含该草稿: ${r.body}', ); check( !idsAfter.contains(secretDraftId), '草稿不在公共 Feed(谓词只放行 published+public+未删)', '草稿泄露进 Feed', ); _passed++; print(''); // ================================================================ // [13/14] 埋点:v3 社区事件上报(platform=android 模拟真机值) // ================================================================ print( '[13/14] POST /api/v1/events(:8082)上报 v3 社区事件' '(platform=android 模拟真机值)', ); final anonymousId = uuidV4(); final sessionId = uuidV4(); final clientTs = DateTime.now().toUtc().toIso8601String(); Map ev(String name, Map props) => { 'eventId': uuidV4(), 'eventName': name, 'eventVersion': 3, 'anonymousId': anonymousId, 'userId': userIdB, 'sessionId': sessionId, 'clientTs': clientTs, 'appVersion': '1.0.0+e2e-m3', 'platform': 'android', 'osVersion': 'android-14', 'props': props, }; final events = [ ev('feed_viewed', { 'feedTab': 'recommend', 'durationMs': 8600, 'impressionCount': 27, 'loadMoreCount': 3, 'refreshCount': 1, }), ev('post_media_upload_succeeded', { 'mediaType': 'image', 'sizeBucket': 'lt_512kb', 'durationMs': 940, }), ev('post_publish_succeeded', { 'durationMs': 1560, 'mediaCount': 1, 'topicCount': 0, 'textLengthBucket': 'lt_200', 'fromDraft': true, }), ev('post_liked', {'source': 'feed'}), ev('post_favorited', {'source': 'detail'}), ev('comment_create_succeeded', { 'durationMs': 720, 'isReply': true, 'textLengthBucket': 'lt_200', }), ev('user_followed', {'source': 'detail'}), ev('page_viewed', {'pageName': 'post_detail', 'referrer': 'home'}), ]; for (final e in events) { print(' eventId: ${e['eventId']} (${e['eventName']})'); } r = await call( 'POST', '$userUrl/api/v1/events', token: tokenB, body: {'events': events}, ); print(' POST /api/v1/events (${events.length} 条) → ${r.status}'); check( r.status == 202 && r.code == 0, '批次受理 202', '埋点上报失败: ${r.status} ${r.body}', ); final results = r.data['results'] as List; final allAccepted = results.every( (e) => (e as Map)['status'] == 'accepted', ); check( r.data['accepted'] == events.length && r.data['duplicated'] == 0 && r.data['rejected'] == 0 && results.length == events.length && allAccepted, '${events.length}/${events.length} 逐条 accepted' '(accepted=${r.data['accepted']}, duplicated=0, rejected=0)', '埋点结果不符: ${r.body}', ); // 红线兜底:白名单外的内容 ID 键被剥离后入库(事件保留)。 final leakEvent = ev('post_liked', {'source': 'feed', 'postId': postId}); r = await call( 'POST', '$userUrl/api/v1/events', token: tokenB, body: { 'events': [leakEvent], }, ); print(' POST /api/v1/events(post_liked 混入白名单外 postId)→ ${r.status}'); check( r.status == 202 && r.data['accepted'] == 1 && (r.data['results'] as List).single is Map && ((r.data['results'] as List).single as Map)['status'] == 'accepted', '白名单外键剥离后事件仍 accepted(隐私红线 ingest 侧兜底,落库无 postId)', '白名单外键处理不符: ${r.body}', ); // 字典边界:被否决的逐卡曝光事件整条 rejected。 final rejectedEvent = ev('post_impression', {}); r = await call( 'POST', '$userUrl/api/v1/events', token: tokenB, body: { 'events': [rejectedEvent], }, ); print(' POST /api/v1/events(字典外 post_impression)→ ${r.status}'); check( r.status == 202 && r.data['rejected'] == 1 && ((r.data['results'] as List).single as Map)['reason'] == 'unknown_event_name', '字典外事件整条 rejected(reason=unknown_event_name),批次仍 202', '字典边界不符: ${r.body}', ); print(' 落库核对(platform.product_events)由报告附 psql 证据。'); print(' E2E_SESSION_ID=$sessionId'); // 供 psql 查证 print(' E2E_LEAK_EVENT_ID=${leakEvent['eventId']}'); _passed++; print(''); // ================================================================ // [14/14] 幂等重放:同 Idempotency-Key 同 hash / 异 hash // ================================================================ print('[14/14] 幂等重放:同 Idempotency-Key 同 hash 返回原帖;异 hash → 409/40905'); final replayKey = uuidV4(); final replayBody = { 'title': 'M3 幂等重放帖', 'content': '同键同 hash 必须返回同一帖(ts=$ts)', 'category': 'general', 'status': 'draft', }; r = await createPost(tokenA, replayBody, idempotencyKey: replayKey); print(' POST /api/v1/posts (Idempotency-Key=, 首发) → ${r.status}'); check(r.status == 201, '首次创建成功(201)', '首次创建失败: ${r.body}'); final replayId = r.data['id'] as String; final replayVersion = r.data['version'] as int; print(' postId: $replayId / version: $replayVersion'); r = await createPost(tokenA, replayBody, idempotencyKey: replayKey); print(' POST /api/v1/posts (同 KEY-1, 同 hash, 重发) → ${r.status}'); check( r.status == 201 && r.data['id'] == replayId && r.data['version'] == replayVersion, '同键同 hash 返回首次结果(id/version 一致),不产生第二帖', '幂等重放不符: ${r.status} ${r.body}', ); r = await call( 'GET', '$communityUrl/api/v1/me/posts?status=draft&limit=100', token: tokenA, ); final myDraftIds = (r.data['items'] as List) .map((e) => (e as Map)['id'] as String) .toList(); check( myDraftIds.where((id) => id == replayId).length == 1 && myDraftIds.length == 2, '我的草稿列表恰 2 条(私密草稿 + 重放帖),重放帖只出现一次(无重复落库)', '草稿列表异常: $myDraftIds', ); final mismatched = {...replayBody, 'content': '同键但内容不同 → 必须 409/40905'}; r = await createPost(tokenA, mismatched, idempotencyKey: replayKey); print( ' POST /api/v1/posts (同 KEY-1, **异 hash**) → ${r.status} / ' 'code ${r.code}', ); check( r.status == 409 && r.code == 40905, '同键异 hash 被拒 409/40905(${r.json['message']})', '同键异 hash 未被拒: ${r.status} ${r.body}', ); r = await call( 'POST', '$communityUrl/api/v1/posts', token: tokenA, body: replayBody, ); print( ' POST /api/v1/posts(**不带** Idempotency-Key)→ ${r.status} / ' 'code ${r.code}', ); check( r.status == 400 && r.code == 40000, 'Idempotency-Key 缺失被拒 400/40000(${r.json['message']})——该头必带', '缺头未被拒: ${r.status} ${r.body}', ); r = await createPost(tokenB, replayBody, idempotencyKey: replayKey); print(' POST /api/v1/posts(B 用同一 KEY-1 值)→ ${r.status}'); check( r.status == 201 && r.data['id'] != replayId, '幂等键按作者隔离:B 用同一键值创建出新帖(id 不同)', '幂等键跨作者串号: ${r.status} ${r.body}', ); _passed++; print(''); print('=== M3 E2E 烟囱测试全部通过 ✓($_passed/14 场景)==='); print('E2E_USERNAME_A=$usernameA'); print('E2E_USER_ID_A=$userIdA'); print('E2E_USER_ID_B=$userIdB'); print('E2E_POST_ID=$postId'); print('E2E_ASSET_ID=$assetId'); print('E2E_DELETED_POST_ID=$doomedId'); print('E2E_DRAFT_POST_ID=$secretDraftId'); print('E2E_SESSION_ID=$sessionId'); } catch (e, stack) { print('✗ 测试异常: $e'); print(stack); exit(1); } finally { client.close(); } } /// 逐元素比较(避免为脚本引入 collection 包依赖)。 class ListEquality { const ListEquality(); bool equals(List a, List b) { if (a.length != b.length) return false; for (var i = 0; i < a.length; i++) { if (a[i] != b[i]) return false; } return true; } }