Files
patbond-flutter/test_e2e_m35_manual.dart
T
lixi fbcd734688
CI / flutter-gates (push) Successful in 3m46s
CI / flutter-gates (pull_request) Successful in 3m44s
新增:M3.5 E2E 烟囱脚本——资料读写/双头像/获赞聚合十场景(v0.4.0 发布门禁)
v0.4.0 发布 checklist 第 2 步:既有三份回归脚本(M1 7/7、M2 11/11、M3 14/14)
对 compose 六容器全绿后,补上 M3.5 新增对外面的验证,与前三份并列于仓库根目录。

覆盖 10 场景 / 95 断言(冻结契约 openapi v1.4.0,32 路径 45 操作 75 schema):
- GET /me 初始形态:nickname/avatarUrl 键恒在且双 null,不外露 avatarAssetId
- PATCH /me 三态:键缺省不改 / 显式 null 清空 / 给值设置;空 patch、纯空白、
  空串、只带未声明字段、非法 UUID、坏 JSON 六格 400/40000;同 body 重放幂等
- 昵称边界按码点计:1 与 32 CJK 可、32 emoji 可(UTF-16 长度 64 却过关)、
  33 两种字符集均拒、btrim 对齐
- 用户头像端到端:两步上传 user_avatar → 挂载 → 预签名 GET 逐字节一致 →
  去签名直访 403(桶私有)
- 宠物头像:PATCH /pets 挂 pet_avatar → 详情与列表两处 avatarUrl 均可下载 →
  显式 null 清除;version 仍必填
- 两域 asset 校验矩阵:幽灵/他人/错用途(含 user_avatar↔pet_avatar 跨域)
  → 404/40405 且防枚举响应体一致;本人用途相符但 uploading → 422/42203;
  失败路径零副作用
- GET /me/community-stats:无 token 401、空数据 0/0 永不 404、自赞计入、
  草稿不计、他人帖不串号、软删后获赞归零、与帖详情 likeCount 对账
- media purpose 白名单三值放行,白名单外 id_card → 400/40000

结果:10/10 全绿,契约偏差 0,无需放宽任何断言(错 purpose 实测 404/40405,
与契约一致)。三份既有脚本逐字未改。

脱敏:token 截断、预签名 URL 签名 query 整体抹为 <SIGNATURE_REDACTED>、
Idempotency-Key 占位。

门禁:flutter analyze 0 issue;dart format --set-exit-if-changed lib test 0 changed
(四份根目录脚本另跑显式检查亦 0 changed);flutter test 597 passed 与基线一致。
patbond-api 与 patbond-doc 代码/契约零改动。

报告:patbond-doc iteration-3.5/08-release-e2e-regression.md

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-14 09:53:35 +08:00

1167 lines
45 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env dart
// ignore_for_file: avoid_print — 手动 E2E 脚本,print 即输出。
/// M3.5 E2E 烟囱测试脚本(v0.4.0 发布门禁):对 compose 真实后端跑通
/// 「用户资料读写 + 用户/宠物头像 + 获赞聚合」三条 M3.5 新增对外面。
///
/// 前置条件:patbond-api 目录执行 `docker compose up -d --build`(六容器:
/// postgres + minio + auth:8081 + user:8082 + pet:8083 + community:8084
/// 运行方式:dart run test_e2e_m35_manual.dart
///
/// 与 test_e2e_manual.dartM17 场景)/ test_e2e_m2_manual.dartM211 场景)
/// / test_e2e_m3_manual.dartM314 场景)并列——本脚本**只覆盖 M3.5 增量面**,
/// 回归由前三份承担。冻结契约 openapi **v1.4.0**32 路径 / 45 操作 / 75 schema)。
///
/// 覆盖 10 个场景:
/// 1. 注册 A、B → `GET /me` 初始形态:nickname/avatarUrl 双 null、**不外露**
/// avatarAssetId、Me 恰 6 字段
/// 2. media `purpose` 白名单三值放行(post_image / user_avatar / pet_avatar),
/// 白名单外(id_card)创建上传 400/40000
/// 3. `PATCH /me` 设昵称 → 响应回显与 `GET /me` 回读逐字段一致(同一 Me 形态)
/// 4. 用户头像端到端:两步上传(user_avatar) → `PATCH /me` 挂载 → avatarUrl
/// 预签名 GET **逐字节一致** → 响应不含 avatarAssetId
/// 5. `PATCH /me` **三态**:只带 nickname 不动头像 / 显式 null 清空昵称(头像留存)
/// / 显式 null 清除头像 / 空 patch 400 / 纯空白 400 / 空串 400 /
/// 只带未声明字段 400 / 非法 UUID 400 / 同 body 重放幂等
/// 6. 昵称边界(长度按**码点**计):1 可 / 32 CJK 可 / **32 emoji 可** /
/// 33 CJK 拒 / 33 emoji 拒 / btrim 对齐
/// 7. 用户头像 asset 四态校验:幽灵 / 他人(B) / 错用途(post_image、pet_avatar)
/// → 404/40405;本人用途相符但 uploading → 422/42203
/// 8. 宠物头像:建档初始 avatarUrl=null 且不外露 avatarAssetId → `PATCH /pets/{id}`
/// 挂 pet_avatar → **详情与列表两处** avatarUrl 均可下载 → 显式 null 清除
/// 9. 宠物头像 asset 校验:错用途(user_avatar、post_image) / 幽灵 → 404/40405
/// uploading → 422/42203version 仍必填(缺失 400/40000
/// 10. `GET /me/community-stats`:无 token 401/40101 → 空数据 0/0(永不 404)→
/// 发布 + **自赞** → 1/1 → 草稿不计 → 第二帖 → 2 → 软删该帖 → 获赞归零
///
/// 敏感信息脱敏:token 截断、预签名 URL 签名 query 整体抹为 `<SIGNATURE_REDACTED>`、
/// Idempotency-Key 占位。
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/me、media 两步上传)
const petUrl = 'http://127.0.0.1:8083'; // patbond-pet
const communityUrl = 'http://127.0.0.1:8084'; // patbond-community
/// 固定测试图:1×1 JPEG(344 字节)。与 M3 脚本同一常量,sha256 见下。
/// complete 仅 HEAD 校验 byteSize/Content-Type,但用真 JPEG 让宽高回填与
/// 预签名 GET 字节比对都走真路径。
const testJpegBase64 =
'/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRof'
'Gh0aHBwcJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPDIzNP/AABEIAAEAAQMBIgACEQEDEQH/'
'xAAfAAABBQEBAQEBAQAAAAAAAAABAgMEBQYHCAkKC//EALUQAAIBAwMCBAMFBQQEAAABfQEC'
'AwAEEQUSITFBBhNRYQcicRQygZGhCCNCscEVUtHwJDNicoIJChYXGBkaJSYnKCkqNDU2Nzg5'
'OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6g4SFhoeIiYqSk5SVlpeYmZqio6Sl'
'pqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2drh4uPk5ebn6Onq8fLz9PX29/j5+v/a'
'AAwDAQACEQMRAD8A9/ooooA//9k=';
/// 上方常量解码后的 sha256(契约照收照存,不做内容核验)。
const testJpegSha256 =
'32142d9c5636535cf882b05c03275d2b58150623df750b059db0bbdab6973ce8';
/// `Me` 冻结形态的键集合(v1.4.0:恰 6 字段,见 components.schemas.Me)。
const meKeys = <String>{
'userId',
'username',
'nickname',
'phone',
'avatarUrl',
'createdAt',
};
final client = HttpClient();
int _passed = 0;
/// token 脱敏:仅留前 20 字符。
String redact(String token) =>
'${token.substring(0, min(20, token.length))}...<REDACTED>';
/// 预签名 URL 脱敏:保留 host + 对象路径,签名 query 整体抹掉。
String redactSignedUrl(String url) {
final uri = Uri.parse(url);
return '${uri.scheme}://${uri.host}:${uri.port}${uri.path}'
'?<SIGNATURE_REDACTED>';
}
/// 昵称脱敏无必要,但超长昵称按码点数概述以保持输出可读。
String describeNickname(String s) =>
'"${s.runes.length <= 8 ? s : '${String.fromCharCodes(s.runes.take(4))}…'}"'
'${s.runes.length} 码点 / UTF-16 长度 ${s.length}';
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<String, dynamic> json;
Resp(this.status, this.body, this.json);
Map<String, dynamic> get data => json['data'] as Map<String, dynamic>;
List<dynamic> get list => json['data'] as List<dynamic>;
int? get code => json['code'] as int?;
String? get message => json['message'] as String?;
}
/// 通用调用。[rawBody] 用于绕过 jsonEncode 直发原文(坏 JSON 等场景)。
Future<Resp> call(
String method,
String url, {
String? token,
Object? body,
String? rawBody,
Map<String, String>? headers,
}) async {
final req = await client.openUrl(method, Uri.parse(url));
if (body != null || rawBody != null) {
req.headers.contentType = ContentType.json;
}
if (token != null) req.headers.set('Authorization', 'Bearer $token');
headers?.forEach(req.headers.set);
if (rawBody != null) {
req.write(rawBody);
} else if (body != null) {
req.write(jsonEncode(body));
}
final resp = await req.close();
final text = await utf8.decodeStream(resp);
Map<String, dynamic> parsed = const {};
try {
parsed = jsonDecode(text) as Map<String, dynamic>;
} catch (_) {
// 非 JSON 响应,parsed 留空 map,由调用方按 status 断言
}
return Resp(resp.statusCode, text, parsed);
}
/// 预签名 PUT 直传:不带 Bearer(鉴权即 URL 签名本身),requiredHeaders 原样携带。
Future<int> presignedPut(
String url,
Map<String, String> 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<void>();
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 = <int>[];
await resp.forEach(chunks.addAll);
return (resp.statusCode, Uint8List.fromList(chunks));
}
String uuidV4() {
final rnd = Random.secure();
final bytes = List<int>.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)}';
}
final _imageBytes = base64Decode(testJpegBase64);
/// 两步上传:createUpload → (可选)预签名 PUT 直传 → (可选)complete 置 ready。
/// [complete] 为 false 时 asset 停在 `uploading`(供 422/42203 取证)。
Future<String> uploadAsset(
String token,
String purpose, {
bool complete = true,
String label = '',
}) async {
var r = await call(
'POST',
'$userUrl/api/v1/media/uploads',
token: token,
body: {
'kind': 'image',
'purpose': purpose,
'mimeType': 'image/jpeg',
'byteSize': _imageBytes.length,
'sha256': testJpegSha256,
},
);
if (r.status != 201) {
fail('createUpload($purpose$label) 失败: ${r.status} ${r.body}');
}
final assetId = r.data['assetId'] as String;
if (!complete) {
print(' asset($purpose$label, 停在 uploading): $assetId');
return assetId;
}
final uploadUrl = r.data['uploadUrl'] as String;
final requiredHeaders = (r.data['requiredHeaders'] as Map).map(
(k, v) => MapEntry(k as String, v as String),
);
final putStatus = await presignedPut(uploadUrl, requiredHeaders, _imageBytes);
if (putStatus != 200) fail('直传失败($purpose$label): HTTP $putStatus');
r = await call(
'POST',
'$userUrl/api/v1/media/uploads/$assetId/complete',
token: token,
);
if (r.status != 200 || r.data['status'] != 'ready') {
fail('complete($purpose$label) 失败: ${r.status} ${r.body}');
}
print(' asset($purpose$label, ready): $assetId');
return assetId;
}
/// 创建帖子(Idempotency-Key 必带;日志里以占位符示意)。
Future<Resp> createPost(String token, Map<String, dynamic> body) => call(
'POST',
'$communityUrl/api/v1/posts',
token: token,
body: body,
headers: {'Idempotency-Key': uuidV4()},
);
/// 断言 Me 形态:键集合恰为冻结的 6 键,且**不外露** avatarAssetId。
void assertMeShape(Map<String, dynamic> me, String where) {
check(
!me.containsKey('avatarAssetId'),
'$where:响应**不含** avatarAssetId(只写不读,"是否有头像"等价于 avatarUrl != null',
'$where 外露了 avatarAssetId: ${me.keys}',
);
check(
me.keys.toSet().difference(meKeys).isEmpty &&
me.containsKey('userId') &&
me.containsKey('username') &&
me.containsKey('nickname') &&
me.containsKey('avatarUrl') &&
me.containsKey('createdAt'),
'$where:Me 形态在冻结的 6 字段内,nickname/avatarUrl 键恒在(值可 null',
'$where Me 形态偏离契约: ${me.keys}',
);
}
Future<Resp> patchMe(String token, Object body) =>
call('PATCH', '$userUrl/api/v1/me', token: token, body: body);
Future<Resp> getMe(String token) =>
call('GET', '$userUrl/api/v1/me', token: token);
Future<Resp> patchPet(String token, String petId, Map<String, dynamic> body) =>
call('PATCH', '$petUrl/api/v1/pets/$petId', token: token, body: body);
void main() async {
final ts = DateTime.now().millisecondsSinceEpoch;
final usernameA = 'e2e_m35_a_$ts';
final usernameB = 'e2e_m35_b_$ts';
const password = 'Test@123456';
final phoneA = '+8613${Random().nextInt(900000000) + 100000000}';
final phoneB = '+8613${Random().nextInt(900000000) + 100000000}';
print('=== Patbond M3.5 E2E 烟囱测试开始(v0.4.0 发布门禁)===');
print('契约:openapi v1.4.0;只覆盖 M3.5 增量面(回归由 M1/M2/M3 三份承担)');
print('账号 A: $usernameA');
print('账号 B: $usernameB');
print('测试图: ${_imageBytes.length} 字节 image/jpegsha256=$testJpegSha256');
print('');
try {
// ================================================================
// [1/10] 注册 A、B → GET /me 初始形态(nickname/avatarUrl 双 null
// ================================================================
print('[1/10] 注册 A、B:8081)→ GET /api/v1/me 初始形态');
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 / 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 / accessToken(B): ${redact(tokenB)}');
r = await getMe(tokenA);
print(' GET /api/v1/me (A, 全新账号) → ${r.status}');
check(
r.status == 200 && r.code == 0,
'GET /me 返回 200',
'GET /me 失败: ${r.body}',
);
var me = r.data;
print(
' userId=${me['userId']} username=${me['username']} '
'nickname=${me['nickname']} avatarUrl=${me['avatarUrl']} '
'phone=${me['phone']}',
);
check(
me.containsKey('nickname') && me['nickname'] == null,
'nickname 键恒在且为 **null**(未设置;/me 不做 username 回退——注册不收昵称)',
'nickname 初始值不符(期望 null: ${me['nickname']}',
);
check(
me.containsKey('avatarUrl') && me['avatarUrl'] == null,
'avatarUrl 键恒在且为 **null**(无头像)',
'avatarUrl 初始值不符(期望 null: ${me['avatarUrl']}',
);
check(
me['userId'] == userIdA && me['username'] == usernameA,
'userId/username 与注册一致(既有字段未回归)',
'userId/username 不符: ${r.body}',
);
assertMeShape(me, 'GET /me 初始态');
_passed++;
print('');
// ================================================================
// [2/10] media purpose 白名单:三值放行;白名单外 id_card 被拒
// ================================================================
print('[2/10] media `purpose` 白名单:三值放行(含 M3.5 新增两值);id_card 被拒');
final assetUserAvatar = await uploadAsset(
tokenA,
'user_avatar',
label: ', A 的用户头像',
);
final assetPetAvatar = await uploadAsset(
tokenA,
'pet_avatar',
label: ', A 的宠物头像',
);
final assetPostImage = await uploadAsset(
tokenA,
'post_image',
label: ', A 的帖子配图',
);
check(
<String>{assetUserAvatar, assetPetAvatar, assetPostImage}.length == 3,
'post_image / **user_avatar** / **pet_avatar** 三用途均登记并置 ready 成功',
'三用途 assetId 有重复',
);
r = await call(
'POST',
'$userUrl/api/v1/media/uploads',
token: tokenA,
body: {
'kind': 'image',
'purpose': 'id_card',
'mimeType': 'image/jpeg',
'byteSize': _imageBytes.length,
'sha256': testJpegSha256,
},
);
print(
' POST /api/v1/media/uploads (purpose=id_card, 白名单外) → ${r.status} / code ${r.code}',
);
check(
r.status == 400 && r.code == 40000,
'白名单外 purpose 创建上传被拒 400/40000${r.message})——白名单是引用侧的类型检查',
'白名单外 purpose 未被拒: ${r.status} ${r.body}',
);
_passed++;
print('');
// ================================================================
// [3/10] PATCH /me 设昵称 → 响应回显与 GET 回读逐字段一致
// ================================================================
print('[3/10] PATCH /api/v1/me 设昵称 → 响应回显与 GET /me 回读一致');
const nick1 = '柴柴小当家';
r = await patchMe(tokenA, {'nickname': nick1});
print(' PATCH /api/v1/me {nickname: "$nick1"} → ${r.status}');
check(
r.status == 200 && r.code == 0,
'PATCH 成功 200(无 version 乐观锁、无 Idempotency-Key',
'PATCH 设昵称失败: ${r.status} ${r.body}',
);
final patchEcho = r.data;
check(
patchEcho['nickname'] == nick1,
'PATCH 响应回显新昵称(返回与 GET 完全相同的 Me 全量形态)',
'PATCH 回显昵称不符: ${patchEcho['nickname']}',
);
assertMeShape(patchEcho, 'PATCH /me 响应');
r = await getMe(tokenA);
me = r.data;
print(' GET /api/v1/me(回读)→ ${r.status} nickname=${me['nickname']}');
check(
r.status == 200 && me['nickname'] == nick1,
'回读昵称与写入一致(持久化生效)',
'回读昵称不符: ${me['nickname']}',
);
check(
me['userId'] == patchEcho['userId'] &&
me['username'] == patchEcho['username'] &&
me['createdAt'] == patchEcho['createdAt'] &&
me['phone'] == patchEcho['phone'] &&
me['avatarUrl'] == patchEcho['avatarUrl'],
'PATCH 回显与 GET 回读**逐字段一致**(两动词同一 Me 形态)',
'PATCH 回显与 GET 回读不一致: ${jsonEncode(patchEcho)} vs ${jsonEncode(me)}',
);
_passed++;
print('');
// ================================================================
// [4/10] 用户头像端到端:挂载 → avatarUrl 预签名 GET 逐字节一致
// ================================================================
print('[4/10] 用户头像端到端:PATCH /me 挂 user_avatar → avatarUrl 下载逐字节比对');
r = await patchMe(tokenA, {'avatarAssetId': assetUserAvatar});
print(' PATCH /api/v1/me {avatarAssetId: $assetUserAvatar} → ${r.status}');
check(
r.status == 200 && r.code == 0,
'挂载用户头像成功 200',
'挂载用户头像失败: ${r.status} ${r.body}',
);
me = r.data;
var avatarUrl = me['avatarUrl'] as String?;
check(
avatarUrl != null,
'avatarUrl 由 null 转为**现签预签名 GET**asset 为 ready 才签)',
'挂载后 avatarUrl 仍为 null: ${r.body}',
);
print(' avatarUrl: ${redactSignedUrl(avatarUrl!)}');
check(
Uri.parse(avatarUrl).queryParameters.containsKey('X-Amz-Signature'),
'avatarUrl 携带 SigV4 query 签名族(私有桶靠签名,不经应用服务器)',
'avatarUrl 无 X-Amz-Signature: ${redactSignedUrl(avatarUrl)}',
);
check(
me['nickname'] == nick1,
'只带 avatarAssetId 的 patch **未动昵称**(三态:键缺省 = 不改)',
'挂头像时昵称被改动: ${me['nickname']}',
);
assertMeShape(me, 'PATCH /me 挂头像后');
var (getStatus, downloaded) = await presignedGet(avatarUrl);
print(' GET <presigned avatarUrl> → $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}',
);
// 每次响应现签:GET /me 再取一次仍可下载(且不缓存 URL)。
r = await getMe(tokenA);
final avatarUrl2 = r.data['avatarUrl'] as String;
final (getStatus2, downloaded2) = await presignedGet(avatarUrl2);
check(
getStatus2 == 200 && downloaded2.length == _imageBytes.length,
'GET /me 的 avatarUrl 亦为现签且可下载(每次响应现签,客户端不得持久化)',
'GET /me 的 avatarUrl 不可下载: HTTP $getStatus2',
);
final unsigned = Uri.parse(avatarUrl).replace(query: '').toString();
final (unsignedStatus, _) = await presignedGet(unsigned);
print(' GET <同一对象但去掉签名> → $unsignedStatus');
check(
unsignedStatus == 403,
'桶保持私有:无签名直访头像对象被存储侧拒绝(403)',
'无签名直访未被拒: HTTP $unsignedStatus',
);
_passed++;
print('');
// ================================================================
// [5/10] PATCH /me 三态语义 + 参数错谱 + 重放幂等
// ================================================================
print('[5/10] PATCH /api/v1/me **三态**:键缺省 / 显式 null / 给值;及参数错谱');
const nick2 = '豆豆';
r = await patchMe(tokenA, {'nickname': nick2});
print(' PATCH {nickname: "$nick2"}(只带昵称)→ ${r.status}');
check(
r.status == 200 &&
r.data['nickname'] == nick2 &&
r.data['avatarUrl'] != null,
'**只带 nickname 的 patch 不影响头像**avatarUrl 仍非 null)——键缺省 = 不改',
'只带昵称的 patch 影响了头像: ${r.body}',
);
r = await patchMe(tokenA, {'nickname': null});
print(' PATCH {nickname: null}(显式 null)→ ${r.status}');
check(
r.status == 200 &&
r.data['nickname'] == null &&
r.data['avatarUrl'] != null,
'**显式 null 清空昵称生效**,且头像留存(清空只作用于携带的键)',
'显式 null 未清空昵称或误清头像: ${r.body}',
);
r = await getMe(tokenA);
check(
r.data['nickname'] == null,
'回读确认昵称已为 null(清空已持久化)',
'清空后回读昵称非 null: ${r.data['nickname']}',
);
r = await patchMe(tokenA, {'avatarAssetId': null});
print(' PATCH {avatarAssetId: null}(显式 null)→ ${r.status}');
check(
r.status == 200 && r.data['avatarUrl'] == null,
'**显式 null 清除头像生效**avatarUrl 回落 null',
'显式 null 未清除头像: ${r.body}',
);
assertMeShape(r.data, 'PATCH /me 清空后');
r = await patchMe(tokenA, <String, dynamic>{});
print(' PATCH {}(空 patch)→ ${r.status} / code ${r.code}');
check(
r.status == 400 && r.code == 40000,
'**空 patch 400/40000**${r.message})——不静默 200,空 PATCH 几乎总是客户端 bug',
'空 patch 未被拒: ${r.status} ${r.body}',
);
r = await patchMe(tokenA, {'nickname': ' '});
print(' PATCH {nickname: " "}(纯空白)→ ${r.status} / code ${r.code}');
check(
r.status == 400 && r.code == 40000,
'**纯空白昵称 400/40000**${r.message})——不隐式清空,清空只留显式 null 一条路',
'纯空白昵称未被拒: ${r.status} ${r.body}',
);
r = await patchMe(tokenA, {'nickname': ''});
print(' PATCH {nickname: ""}(空串)→ ${r.status} / code ${r.code}');
check(
r.status == 400 && r.code == 40000,
'空串昵称 400/40000(与纯空白同判)',
'空串昵称未被拒: ${r.status} ${r.body}',
);
r = await patchMe(tokenA, {'bio': '只带未声明字段'});
print(' PATCH {bio: …}(只带未声明字段)→ ${r.status} / code ${r.code}');
check(
r.status == 400 && r.code == 40000,
'只带未声明字段 = 空 patch400/40000bio 列存在但 M3.5 未开放读写)',
'只带未声明字段未被拒: ${r.status} ${r.body}',
);
r = await patchMe(tokenA, {'avatarAssetId': 'not-a-uuid'});
print(
' PATCH {avatarAssetId: "not-a-uuid"} → ${r.status} / code ${r.code}',
);
check(
r.status == 400 && r.code == 40000,
'非法 UUID 的 avatarAssetId 400/40000(参数错先于引用校验)',
'非法 UUID 未被拒: ${r.status} ${r.body}',
);
r = await call(
'PATCH',
'$userUrl/api/v1/me',
token: tokenA,
rawBody: '{"nickname": ',
);
print(' PATCH <坏 JSON> → ${r.status} / code ${r.code}');
check(
r.status == 400 && r.code == 40000,
'非法 JSON body 400/40000',
'坏 JSON 未被拒: ${r.status} ${r.body}',
);
r = await call(
'PATCH',
'$userUrl/api/v1/me',
body: {'nickname': '无 token'},
);
print(' PATCH(无 token)→ ${r.status} / code ${r.code}');
check(
r.status == 401 && r.code == 40101,
'无 token 401/40101(写入口同样强制鉴权)',
'无 token 未被拒: ${r.status} ${r.body}',
);
// 重放幂等:同 body 连发两次,第二次仍 200 且状态与首次一致。
final replayBody = {'nickname': '重放昵称', 'avatarAssetId': assetUserAvatar};
r = await patchMe(tokenA, replayBody);
check(r.status == 200, '重放取证:首次 PATCH 成功 200', '重放首发失败: ${r.body}');
final firstNick = r.data['nickname'];
final firstHasAvatar = r.data['avatarUrl'] != null;
r = await patchMe(tokenA, replayBody);
print(' PATCH(同 body 重放第二次)→ ${r.status}');
check(
r.status == 200 &&
r.data['nickname'] == firstNick &&
(r.data['avatarUrl'] != null) == firstHasAvatar,
'同 body 重放第二次仍 200 且状态与首次一致(无 Idempotency-Key,天然幂等)',
'重放结果不一致: ${r.body}',
);
_passed++;
print('');
// ================================================================
// [6/10] 昵称边界:长度按码点计(1 / 32 CJK / 32 emoji / 33 拒)
// ================================================================
print('[6/10] 昵称边界(长度按**码点**计,btrim 后 1~32');
final nick1cp = '柴';
final nick32cjk = '猫' * 32;
final nick33cjk = '猫' * 33;
final nick32emoji = '🐕' * 32; // 32 码点 / UTF-16 长度 64
final nick33emoji = '🐕' * 33; // 33 码点 / UTF-16 长度 66
r = await patchMe(tokenA, {'nickname': nick1cp});
print(' PATCH ${describeNickname(nick1cp)}${r.status}');
check(
r.status == 200 && r.data['nickname'] == nick1cp,
'**1 码点**昵称接受(下边界)',
'1 码点昵称被拒: ${r.status} ${r.body}',
);
r = await patchMe(tokenA, {'nickname': nick32cjk});
print(' PATCH ${describeNickname(nick32cjk)}${r.status}');
check(
r.status == 200 && r.data['nickname'] == nick32cjk,
'**32 CJK 码点**昵称接受(上边界)',
'32 CJK 昵称被拒: ${r.status} ${r.body}',
);
r = await patchMe(tokenA, {'nickname': nick32emoji});
print(' PATCH ${describeNickname(nick32emoji)}${r.status}');
check(
r.status == 200 && r.data['nickname'] == nick32emoji,
'**32 emoji 码点**昵称接受(UTF-16 长度 64 却过关 → 长度确按码点计,'
'非 String.length()',
'32 emoji 昵称被拒(疑按 UTF-16 长度校验): ${r.status} ${r.body}',
);
r = await getMe(tokenA);
check(
r.data['nickname'] == nick32emoji &&
(r.data['nickname'] as String).runes.length == 32,
'回读 32 emoji 昵称逐字一致(存储与返回都无截断)',
'emoji 昵称回读不符: ${r.data['nickname']}',
);
r = await patchMe(tokenA, {'nickname': nick33cjk});
print(
' PATCH ${describeNickname(nick33cjk)}${r.status} / code ${r.code}',
);
check(
r.status == 400 && r.code == 40000,
'**33 CJK 码点**昵称被拒 400/40000${r.message}',
'33 CJK 昵称未被拒: ${r.status} ${r.body}',
);
r = await patchMe(tokenA, {'nickname': nick33emoji});
print(
' PATCH ${describeNickname(nick33emoji)}${r.status} / code ${r.code}',
);
check(
r.status == 400 && r.code == 40000,
'**33 emoji 码点**昵称被拒 400/40000(上边界对两种字符集一致)',
'33 emoji 昵称未被拒: ${r.status} ${r.body}',
);
r = await patchMe(tokenA, {'nickname': ' 豆豆 '});
print(' PATCH {nickname: " 豆豆 "}(前后空白)→ ${r.status}');
check(
r.status == 200 && r.data['nickname'] == '豆豆',
'btrim 对齐:" 豆豆 " 存为 "豆豆"(长度校验在 btrim 之后)',
'btrim 未对齐: ${r.data['nickname']}',
);
_passed++;
print('');
// ================================================================
// [7/10] 用户头像 asset 四态校验:40405 五路 + 42203
// ================================================================
print('[7/10] 用户头像 asset 校验:幽灵/他人/错用途 → 404/40405uploading → 422/42203');
final ghostAssetId = uuidV4();
final assetOfB = await uploadAsset(tokenB, 'user_avatar', label: ', B 的头像');
final notFoundCases = <String, String>{
'幽灵 assetId': ghostAssetId,
'他人(B)的 user_avatar': assetOfB,
'错用途:post_image': assetPostImage,
'错用途:pet_avatar(跨域头像不通用)': assetPetAvatar,
};
final notFoundBodies = <String>[];
for (final entry in notFoundCases.entries) {
r = await patchMe(tokenA, {'avatarAssetId': entry.value});
print(' PATCH /me ← ${entry.key}${r.status} / code ${r.code}');
check(
r.status == 404 && r.code == 40405,
'${entry.key} → 404/40405${r.message}',
'${entry.key} 未按 40405 拒绝: ${r.status} ${r.body}',
);
notFoundBodies.add(r.body);
}
check(
notFoundBodies.take(2).toSet().length == 1,
'幽灵 id 与他人 asset 响应体逐字节一致(防枚举合并:不能据差异探出 id 是真的)',
'幽灵与他人 asset 响应可区分: $notFoundBodies',
);
final assetUploading = await uploadAsset(
tokenA,
'user_avatar',
complete: false,
label: ', 真未直传',
);
r = await patchMe(tokenA, {'avatarAssetId': assetUploading});
print(
' PATCH /me ← 本人 user_avatar 但仍 uploading → ${r.status} / code ${r.code}',
);
check(
r.status == 422 && r.code == 42203,
'本人、用途相符但未就绪 → 422/42203(${r.message})——状态机拒绝,可重试',
'未就绪 asset 未按 42203 拒绝: ${r.status} ${r.body}',
);
r = await getMe(tokenA);
check(
r.status == 200 && r.data['avatarUrl'] != null,
'以上 5 次失败的 PATCH **均未产生副作用**(头像仍是场景 5 重放时挂上的那张)',
'失败的 PATCH 改动了头像: ${r.body}',
);
_passed++;
print('');
// ================================================================
// [8/10] 宠物头像:详情与列表两处 avatarUrl + 三态清除
// ================================================================
print('[8/10] 宠物头像:建档 → PATCH 挂 pet_avatar → 详情+列表 avatarUrl → 清除');
r = await call(
'POST',
'$petUrl/api/v1/pets',
token: tokenA,
body: {
'name': 'M3.5 头像喵',
'species': 'cat',
'sex': 'female',
'customBreedName': '中华田园猫',
},
);
print(' POST /api/v1/pets → ${r.status}');
check(
r.status == 201 && r.code == 0,
'建档成功(201',
'建档失败: ${r.status} ${r.body}',
);
final petId = r.data['id'] as String;
var petVersion = r.data['version'] as int;
print(' petId: $petId / version: $petVersion');
check(
r.data.containsKey('avatarUrl') && r.data['avatarUrl'] == null,
'创建响应 avatarUrl 键恒在且为 null(新建无头像)',
'创建响应 avatarUrl 不符: ${r.data['avatarUrl']}',
);
check(
!r.data.containsKey('avatarAssetId'),
'Pet 响应**不含** avatarAssetId(与 Me 同一纪律:只写不读)',
'Pet 响应外露 avatarAssetId: ${r.data.keys}',
);
r = await patchPet(tokenA, petId, {
'version': petVersion,
'avatarAssetId': assetPetAvatar,
});
print(' PATCH /api/v1/pets/$petId {version, avatarAssetId} → ${r.status}');
check(
r.status == 200 && r.code == 0,
'挂宠物头像成功 200(仅头像字段 → WRITE 档)',
'挂宠物头像失败: ${r.status} ${r.body}',
);
check(
r.data['version'] == petVersion + 1,
'version $petVersion${r.data['version']}(头像与资料共用同一把乐观锁)',
'version 未 +1: ${r.data['version']}',
);
petVersion = r.data['version'] as int;
final petAvatarUrl = r.data['avatarUrl'] as String?;
check(
petAvatarUrl != null,
'PATCH 响应 avatarUrl 现签非空',
'PATCH 响应 avatarUrl 为 null: ${r.body}',
);
print(' avatarUrl (PATCH 响应): ${redactSignedUrl(petAvatarUrl!)}');
check(
!r.data.containsKey('avatarAssetId'),
'PATCH 响应仍不外露 avatarAssetId',
'PATCH 响应外露 avatarAssetId: ${r.data.keys}',
);
check(
r.data['name'] == 'M3.5 头像喵' && r.data['species'] == 'cat',
'只带 avatarAssetId 的 patch 未动资料字段(name/species 原样)',
'资料字段被改动: ${r.body}',
);
r = await call('GET', '$petUrl/api/v1/pets/$petId', token: tokenA);
final detailUrl = r.data['avatarUrl'] as String?;
print(' GET /api/v1/pets/$petId(详情)→ ${r.status}');
check(
r.status == 200 && detailUrl != null,
'**详情**处 avatarUrl 非空(现签)',
'详情 avatarUrl 为 null: ${r.body}',
);
var (detailStatus, detailBytes) = await presignedGet(detailUrl!);
print(
' GET <详情 avatarUrl> ${redactSignedUrl(detailUrl)} → '
'$detailStatus${detailBytes.length} 字节)',
);
check(
detailStatus == 200 &&
const ListEquality().equals(detailBytes, _imageBytes),
'详情 avatarUrl 可下载且与上传逐字节一致',
'详情 avatarUrl 下载不符: HTTP $detailStatus / ${detailBytes.length} 字节',
);
r = await call('GET', '$petUrl/api/v1/pets', token: tokenA);
print(' GET /api/v1/pets(列表)→ ${r.status}');
final listed =
r.list.firstWhere((e) => (e as Map)['id'] == petId)
as Map<String, dynamic>;
final listUrl = listed['avatarUrl'] as String?;
check(
r.status == 200 && listUrl != null,
'**列表**处 avatarUrl 亦非空(四处 Pet 响应统一扩字段)',
'列表 avatarUrl 为 null: ${jsonEncode(listed)}',
);
check(
!listed.containsKey('avatarAssetId'),
'列表项同样不外露 avatarAssetId',
'列表项外露 avatarAssetId: ${listed.keys}',
);
final (listStatus, listBytes) = await presignedGet(listUrl!);
print(' GET <列表 avatarUrl> → $listStatus${listBytes.length} 字节)');
check(
listStatus == 200 && const ListEquality().equals(listBytes, _imageBytes),
'列表 avatarUrl 可下载且字节一致(两处 URL 各自现签、均有效)',
'列表 avatarUrl 下载不符: HTTP $listStatus',
);
r = await patchPet(tokenA, petId, {
'version': petVersion,
'avatarAssetId': null,
});
print(
' PATCH /api/v1/pets/$petId {avatarAssetId: null}(三态清除)→ ${r.status}',
);
check(
r.status == 200 && r.data['avatarUrl'] == null,
'**显式 null 清除宠物头像生效**avatarAssetId 是 UpdatePetRequest 唯一的三态字段)',
'宠物头像未被清除: ${r.body}',
);
petVersion = r.data['version'] as int;
r = await call('GET', '$petUrl/api/v1/pets/$petId', token: tokenA);
check(
r.data['avatarUrl'] == null,
'回读确认宠物头像已清除',
'清除后回读 avatarUrl 非 null: ${r.data['avatarUrl']}',
);
_passed++;
print('');
// ================================================================
// [9/10] 宠物头像 asset 校验 + version 仍必填
// ================================================================
print('[9/10] 宠物头像 asset 校验:错用途/幽灵 → 404/40405uploading → 422/42203');
final petNotFoundCases = <String, String>{
'错用途:user_avatar(跨域头像不通用)': assetUserAvatar,
'错用途:post_image': assetPostImage,
'幽灵 assetId': uuidV4(),
'他人(B)的 asset': assetOfB,
};
for (final entry in petNotFoundCases.entries) {
r = await patchPet(tokenA, petId, {
'version': petVersion,
'avatarAssetId': entry.value,
});
print(' PATCH /pets/{id} ← ${entry.key}${r.status} / code ${r.code}');
check(
r.status == 404 && r.code == 40405,
'${entry.key} → 404/40405${r.message}',
'${entry.key} 未按 40405 拒绝: ${r.status} ${r.body}',
);
}
final petAssetUploading = await uploadAsset(
tokenA,
'pet_avatar',
complete: false,
label: ', 真未直传',
);
r = await patchPet(tokenA, petId, {
'version': petVersion,
'avatarAssetId': petAssetUploading,
});
print(
' PATCH /pets/{id} ← 本人 pet_avatar 但仍 uploading → ${r.status} / code ${r.code}',
);
check(
r.status == 422 && r.code == 42203,
'本人、用途相符但未就绪 → 422/42203(${r.message}',
'未就绪 pet asset 未按 42203 拒绝: ${r.status} ${r.body}',
);
r = await patchPet(tokenA, petId, {'avatarAssetId': assetPetAvatar});
print(
' PATCH /pets/{id}(缺 version,只带 avatarAssetId)→ ${r.status} / code ${r.code}',
);
check(
r.status == 400 && r.code == 40000,
'version 仍必填:缺失 400/40000${r.message})——即便只改头像',
'缺 version 未被拒: ${r.status} ${r.body}',
);
r = await call('GET', '$petUrl/api/v1/pets/$petId', token: tokenA);
check(
r.status == 200 &&
r.data['avatarUrl'] == null &&
r.data['version'] == petVersion,
'以上 6 次失败的 PATCH 均无副作用(头像仍为 nullversion 未 +1',
'失败的 PATCH 产生了副作用: ${r.body}',
);
_passed++;
print('');
// ================================================================
// [10/10] GET /me/community-stats:空数据 0/0 → 自赞 → 软删归零
// ================================================================
print('[10/10] GET /api/v1/me/community-stats:空数据 0/0 → 发帖自赞 1/1 → 软删归零');
r = await call('GET', '$communityUrl/api/v1/me/community-stats');
print(' GET /me/community-stats(无 token)→ ${r.status} / code ${r.code}');
check(
r.status == 401 && r.code == 40101,
'无 token 401/40101(唯一的错误谱)',
'无 token 未被拒: ${r.status} ${r.body}',
);
r = await call(
'GET',
'$communityUrl/api/v1/me/community-stats',
token: tokenA,
);
print(
' GET /me/community-statsA 尚未发帖)→ ${r.status} ${jsonEncode(r.data)}',
);
check(
r.status == 200 && r.code == 0,
'空数据返回 200(**永不 404**:任何已认证用户都有 stats',
'空数据未返回 200: ${r.status} ${r.body}',
);
check(
r.data['receivedLikeCount'] == 0 && r.data['publishedPostCount'] == 0,
'空数据两字段均为 **0 而非 null**COALESCE(SUM(like_count), 0)',
'空数据不为 0/0: ${r.body}',
);
check(
r.data.keys.toSet().length == 2 &&
r.data.containsKey('receivedLikeCount') &&
r.data.containsKey('publishedPostCount'),
'CommunityStats 恰两字段(无多余键)',
'CommunityStats 形态不符: ${r.data.keys}',
);
r = await createPost(tokenA, {
'title': 'M3.5 获赞聚合取证帖',
'content': 'M3.5 烟囱:本帖将被作者自赞,用于 community-stats 对账(ts=$ts',
'category': 'general',
'status': 'published',
});
print(
' POST /api/v1/posts (status=published, Idempotency-Key=<KEY-1>) → ${r.status}',
);
check(r.status == 201 && r.code == 0, '发布帖创建成功(201', '发帖失败: ${r.body}');
final statsPostId = r.data['id'] as String;
print(' postId: $statsPostId');
r = await call(
'GET',
'$communityUrl/api/v1/me/community-stats',
token: tokenA,
);
print(' GET /me/community-stats(发布 1 帖,未获赞)→ ${jsonEncode(r.data)}');
check(
r.data['publishedPostCount'] == 1 && r.data['receivedLikeCount'] == 0,
'作品数 1、获赞 0(发布即计入作品集合)',
'发布后计数不符: ${r.body}',
);
r = await call(
'PUT',
'$communityUrl/api/v1/posts/$statsPostId/like',
token: tokenA,
);
print(
' PUT /posts/{id}/like**A 自赞**)→ ${r.status} ${jsonEncode(r.data)}',
);
check(
r.status == 200 && r.data['liked'] == true && r.data['likeCount'] == 1,
'自赞成功,帖级 likeCount=1',
'自赞不符: ${r.status} ${r.body}',
);
r = await call(
'GET',
'$communityUrl/api/v1/me/community-stats',
token: tokenA,
);
print(' GET /me/community-stats(自赞后)→ ${jsonEncode(r.data)}');
check(
r.data['receivedLikeCount'] == 1 && r.data['publishedPostCount'] == 1,
'**receivedLikeCount=1 / publishedPostCount=1**:自己赞自己**计入**'
'(与帖子详情 likeCount 同一口径,两处数字对得上)',
'自赞后计数不符: ${r.body}',
);
r = await call(
'GET',
'$communityUrl/api/v1/posts/$statsPostId',
token: tokenA,
);
check(
r.data['likeCount'] == 1,
'帖子详情 likeCount=1 与 receivedLikeCount 对账一致',
'两处口径不一致: 详情 ${r.data['likeCount']}',
);
r = await createPost(tokenA, {
'title': 'M3.5 草稿(不应计入作品数)',
'content': '草稿尚非作品,其获赞也不可能存在(ts=$ts',
'status': 'draft',
});
check(
r.status == 201 && r.data['status'] == 'draft',
'草稿创建成功(201',
'草稿创建失败: ${r.body}',
);
r = await call(
'GET',
'$communityUrl/api/v1/me/community-stats',
token: tokenA,
);
print(' GET /me/community-stats(新增 1 草稿后)→ ${jsonEncode(r.data)}');
check(
r.data['publishedPostCount'] == 1 && r.data['receivedLikeCount'] == 1,
'**草稿不计入**作品数(作品数仍为 1)',
'草稿被计入: ${r.body}',
);
r = await createPost(tokenA, {
'title': 'M3.5 第二篇发布帖',
'content': '第二篇已发布帖,作品数应累加到 2(ts=$ts',
'status': 'published',
});
check(r.status == 201, '第二篇发布帖创建成功(201', '第二帖失败: ${r.body}');
r = await call(
'GET',
'$communityUrl/api/v1/me/community-stats',
token: tokenA,
);
print(' GET /me/community-stats(发布 2 帖)→ ${jsonEncode(r.data)}');
check(
r.data['publishedPostCount'] == 2 && r.data['receivedLikeCount'] == 1,
'作品数累加为 2,获赞仍为 1(多帖求和口径正确)',
'两帖计数不符: ${r.body}',
);
// 他人帖不串号:B 发布并自赞一帖,A 的 stats 不受影响。
r = await createPost(tokenB, {
'title': 'B 的帖(不应进 A 的 stats',
'content': 'B 的已发布帖 + 自赞,用于验证主体隔离(ts=$ts',
'status': 'published',
});
check(r.status == 201, 'B 发布帖创建成功(201', 'B 发帖失败: ${r.body}');
final postOfB = r.data['id'] as String;
r = await call(
'PUT',
'$communityUrl/api/v1/posts/$postOfB/like',
token: tokenB,
);
check(r.status == 200, 'B 自赞成功', 'B 自赞失败: ${r.body}');
r = await call(
'GET',
'$communityUrl/api/v1/me/community-stats',
token: tokenA,
);
print(' GET /me/community-statsB 也发帖自赞后,A 视角)→ ${jsonEncode(r.data)}');
check(
r.data['publishedPostCount'] == 2 && r.data['receivedLikeCount'] == 1,
'他人帖**不串号**进 A 的 stats(主体恒为 token 里的调用者)',
'A 的 stats 被 B 的帖污染: ${r.body}',
);
r = await call(
'DELETE',
'$communityUrl/api/v1/posts/$statsPostId',
token: tokenA,
);
print(' DELETE /api/v1/posts/$statsPostId(软删被赞的那帖)→ ${r.status}');
check(
r.status == 200 && r.code == 0,
'软删成功',
'软删失败: ${r.status} ${r.body}',
);
r = await call(
'GET',
'$communityUrl/api/v1/me/community-stats',
token: tokenA,
);
print(' GET /me/community-stats(软删后)→ ${jsonEncode(r.data)}');
check(
r.data['receivedLikeCount'] == 0 && r.data['publishedPostCount'] == 1,
'**软删帖后获赞归零**(1→0)、作品数回落 1(删帖即撤回其数字,'
'与 /me/posts、Feed 可见性一致)',
'软删后计数不符: ${r.body}',
);
_passed++;
print('');
print('=== M3.5 E2E 烟囱测试全部通过 ✓($_passed/10 场景)===');
print('E2E_USERNAME_A=$usernameA');
print('E2E_USER_ID_A=$userIdA');
print('E2E_USER_ID_B=$userIdB');
print('E2E_USER_AVATAR_ASSET_ID=$assetUserAvatar');
print('E2E_PET_AVATAR_ASSET_ID=$assetPetAvatar');
print('E2E_PET_ID=$petId');
print('E2E_STATS_POST_ID=$statsPostId(已软删)');
} catch (e, stack) {
print('✗ 测试异常: $e');
print(stack);
exit(1);
} finally {
client.close();
}
}
/// 逐元素比较(避免为脚本引入 collection 包依赖)。
class ListEquality {
const ListEquality();
bool equals<T>(List<T> a, List<T> 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;
}
}