#!/usr/bin/env dart // ignore_for_file: avoid_print — 手动 E2E 脚本,print 即输出。 /// M2 E2E 烟囱测试脚本(T2-18 收官):对 compose 真实后端跑通 M2 完整链路。 /// /// 前置条件:patbond-api 目录执行 `docker compose up -d` /// 运行方式:dart run test_e2e_m2_manual.dart /// /// 覆盖 11 个场景(工单 T2-18 定义的链路): /// 1. 注册账号 A → 登录 /// 2. 建档(POST /pets,含品种)→ 列表/详情读回核对 /// 3. 记体重 ×2 → 列表分页读回(cursor 分页两页取齐) /// 4. 登记疫苗(scheduled)→ 标记完成(PATCH,version 乐观锁) /// 5. 记健康事件(金额整数分)→ 时间线读回 /// 6. 创建提醒 → 标记完成(completedAt 校验) /// 7. 摘要核对:最新体重 / 疫苗进度 / 下次接种 / 当月花费逐项断言 /// 8. 权限拒绝:账号 B 访问 A 的宠物四路 → 全部 404/40401 且响应体一致(防枚举) /// 9. 跨设备读取:账号 A 重新登录(新会话)→ 全量数据读回核对 /// 10. 埋点链路:POST /api/v1/events 上报 v2 事件 → 202 逐条 accepted /// 11. 乐观锁冲突:两次 PATCH 同一 version → 第二次 409/40902 /// /// 真机专属项(Android 事件落库观察、SessionTracker 30min 手测)按方案 A 挂起, /// 不在本脚本范围内。 library; import 'dart:convert'; import 'dart:io'; import 'dart:math'; const authUrl = 'http://127.0.0.1:8081'; // patbond-auth const userUrl = 'http://127.0.0.1:8082'; // patbond-user(/me、/events) const petUrl = 'http://127.0.0.1:8083'; // patbond-pet(pets 域 12 路径) final client = HttpClient(); int _passed = 0; String redact(String token) => '${token.substring(0, min(20, token.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 json; Resp(this.status, this.body, this.json); } 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); } 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)}'; } void main() async { final ts = DateTime.now().millisecondsSinceEpoch; final usernameA = 'e2e_m2_a_$ts'; final usernameB = 'e2e_m2_b_$ts'; const password = 'Test@123456'; final phoneA = '+8613${Random().nextInt(900000000) + 100000000}'; final phoneB = '+8613${Random().nextInt(900000000) + 100000000}'; print('=== Patbond M2 E2E 烟囱测试开始(T2-18)==='); print('账号 A: $usernameA'); print('账号 B: $usernameB'); print(''); try { // ================================================================ // [1/11] 注册账号 A → 登录 // ================================================================ print('[1/11] 注册账号 A → 登录'); var r = await call( 'POST', '$authUrl/api/v1/auth/register', body: {'username': usernameA, 'phone': phoneA, 'password': password}, ); print(' POST /api/v1/auth/register → ${r.status}'); check( r.status == 200 && r.json['code'] == 0, '注册成功', '注册失败: ${r.status} ${r.body}', ); final userIdA = (r.json['data'] as Map)['userId'] as String; print(' userId(A): $userIdA'); print( ' accessToken: ${redact((r.json['data'] as Map)['accessToken'] as String)}', ); r = await call( 'POST', '$authUrl/api/v1/auth/login', body: {'username': usernameA, 'password': password}, ); print(' POST /api/v1/auth/login → ${r.status}'); check( r.status == 200 && r.json['code'] == 0, '登录成功(设备 1 会话)', '登录失败: ${r.status} ${r.body}', ); var tokenA = (r.json['data'] as Map)['accessToken'] as String; print(' accessToken: ${redact(tokenA)}'); _passed++; print(''); // ================================================================ // [2/11] 建档(含品种)→ 列表/详情读回核对 // ================================================================ print('[2/11] 建档(POST /pets,含品种)→ 列表/详情读回核对'); r = await call('GET', '$petUrl/api/v1/breeds?species=dog', token: tokenA); print(' GET /api/v1/breeds?species=dog → ${r.status}'); check( r.status == 200 && (r.json['data'] as List).isNotEmpty, '品种目录返回 ${(r.json['data'] as List).length} 条', '品种目录读取失败: ${r.body}', ); final breed = (r.json['data'] as List).first as Map; final breedId = breed['id'] as String; final breedName = breed['displayName'] as String; print(' 选用品种: $breedName ($breedId)'); r = await call( 'POST', '$petUrl/api/v1/pets', token: tokenA, body: { 'name': '旺财M2', 'species': 'dog', 'breedId': breedId, 'sex': 'male', 'birthDate': '2024-05-01', 'birthDateEstimated': false, 'personality': '活泼', }, ); print(' POST /api/v1/pets → ${r.status}'); check( r.status == 201 && r.json['code'] == 0, '建档成功(201)', '建档失败: ${r.status} ${r.body}', ); final pet = r.json['data'] as Map; final petId = pet['id'] as String; final petVersion = pet['version'] as int; print(' petId: $petId'); print( ' myRole: ${pet['myRole']} / version: $petVersion / ' 'breedDisplayName: ${pet['breedDisplayName']}', ); check(pet['myRole'] == 'owner', '创建者角色为 owner', 'myRole 非 owner'); check( pet['breedDisplayName'] == breedName, '品种展示名解出一致', 'breedDisplayName 不一致: ${pet['breedDisplayName']}', ); r = await call('GET', '$petUrl/api/v1/pets', token: tokenA); print(' GET /api/v1/pets → ${r.status}'); final petList = r.json['data'] as List; check( r.status == 200 && petList.length == 1 && (petList.first as Map)['id'] == petId, '列表读回 1 只宠物且 id 一致', '列表读回不符: ${r.body}', ); r = await call('GET', '$petUrl/api/v1/pets/$petId', token: tokenA); print(' GET /api/v1/pets/$petId → ${r.status}'); final detail = r.json['data'] as Map; check( r.status == 200 && detail['name'] == '旺财M2' && detail['species'] == 'dog' && detail['breedId'] == breedId && detail['status'] == 'active', '详情读回核对通过(name/species/breedId/status)', '详情读回不符: ${r.body}', ); _passed++; print(''); // ================================================================ // [3/11] 记体重 ×2 → 列表分页读回 // ================================================================ print('[3/11] 记体重 ×2 → 列表 cursor 分页读回'); final now = DateTime.now().toUtc(); final measured1 = now.subtract(const Duration(days: 2)).toIso8601String(); final measured2 = now.subtract(const Duration(days: 1)).toIso8601String(); r = await call( 'POST', '$petUrl/api/v1/pets/$petId/weights', token: tokenA, body: {'weightKg': 8.20, 'measuredAt': measured1, 'source': 'manual'}, ); print(' POST /weights (8.20kg, $measured1) → ${r.status}'); check(r.status == 201, '第一条体重创建成功', '体重创建失败: ${r.body}'); r = await call( 'POST', '$petUrl/api/v1/pets/$petId/weights', token: tokenA, body: { 'weightKg': 8.45, 'measuredAt': measured2, 'source': 'manual', 'note': 'M2 烟囱', }, ); print(' POST /weights (8.45kg, $measured2) → ${r.status}'); check(r.status == 201, '第二条体重创建成功', '体重创建失败: ${r.body}'); r = await call( 'GET', '$petUrl/api/v1/pets/$petId/weights?limit=1', token: tokenA, ); print(' GET /weights?limit=1 → ${r.status}'); var page = r.json['data'] as Map; var items = page['items'] as List; check( r.status == 200 && items.length == 1 && (items.first as Map)['weightKg'] == 8.45 && page['hasMore'] == true && page['nextCursor'] != null, '第一页:最新体重 8.45kg 在前,hasMore=true,nextCursor 非空', '第一页分页不符: ${r.body}', ); final cursor = page['nextCursor'] as String; r = await call( 'GET', '$petUrl/api/v1/pets/$petId/weights?limit=1' '&cursor=${Uri.encodeQueryComponent(cursor)}', token: tokenA, ); print(' GET /weights?limit=1&cursor=... → ${r.status}'); page = r.json['data'] as Map; items = page['items'] as List; check( r.status == 200 && items.length == 1 && (items.first as Map)['weightKg'] == 8.2 && page['hasMore'] == false && page['nextCursor'] == null, '第二页:8.20kg,hasMore=false,nextCursor=null', '第二页分页不符: ${r.body}', ); _passed++; print(''); // ================================================================ // [4/11] 登记疫苗(scheduled)→ 标记完成(PATCH,乐观锁) // ================================================================ print('[4/11] 登记疫苗(scheduled)→ 标记完成(PATCH + version)'); r = await call( 'GET', '$petUrl/api/v1/vaccine-catalog?species=dog', token: tokenA, ); print(' GET /api/v1/vaccine-catalog?species=dog → ${r.status}'); check( r.status == 200 && (r.json['data'] as List).isNotEmpty, '疫苗目录返回 ${(r.json['data'] as List).length} 条', '疫苗目录读取失败: ${r.body}', ); final vaccine = (r.json['data'] as List).first as Map; final vaccineId = vaccine['id'] as String; final vaccineName = vaccine['name'] as String; print(' 选用疫苗: $vaccineName ($vaccineId)'); final today = now.toIso8601String().substring(0, 10); final nextDue = now .add(const Duration(days: 365)) .toIso8601String() .substring(0, 10); r = await call( 'POST', '$petUrl/api/v1/pets/$petId/vaccinations', token: tokenA, body: { 'vaccineId': vaccineId, 'seriesKey': 'primary', 'doseNo': 1, 'doseLabel': '第一针', 'status': 'scheduled', 'plannedOn': today, }, ); print(' POST /vaccinations (scheduled, plannedOn=$today) → ${r.status}'); check( r.status == 201 && r.json['code'] == 0, '疫苗登记成功(scheduled)', '疫苗登记失败: ${r.status} ${r.body}', ); final vacc = r.json['data'] as Map; final vaccinationId = vacc['id'] as String; final vaccVersion = vacc['version'] as int; print( ' vaccinationId: $vaccinationId / version: $vaccVersion / ' 'vaccineName: ${vacc['vaccineName']}', ); r = await call( 'PATCH', '$petUrl/api/v1/vaccinations/$vaccinationId', token: tokenA, body: { 'version': vaccVersion, 'status': 'completed', 'administeredOn': today, 'nextDueOn': nextDue, }, ); print( ' PATCH /vaccinations/$vaccinationId ' '(→completed, version=$vaccVersion) → ${r.status}', ); final vaccDone = r.json['data'] as Map; check( r.status == 200 && vaccDone['status'] == 'completed' && vaccDone['administeredOn'] == today && vaccDone['nextDueOn'] == nextDue && vaccDone['version'] == vaccVersion + 1, '标记完成成功,version $vaccVersion→${vaccDone['version']},' 'administeredOn/nextDueOn 回读一致', '疫苗标记完成不符: ${r.body}', ); _passed++; print(''); // ================================================================ // [5/11] 记健康事件(金额整数分)→ 时间线读回 // ================================================================ print('[5/11] 记健康事件(amountCents 整数分)→ 时间线读回'); final occurredAt = now.toIso8601String(); const amountCents = 12500; // 125.00 元 r = await call( 'POST', '$petUrl/api/v1/pets/$petId/health-events', token: tokenA, body: { 'eventType': 'medical', 'occurredAt': occurredAt, 'title': 'M2 烟囱体检', 'notes': '含金额整数分核对', 'amountCents': amountCents, }, ); print( ' POST /health-events (medical, amountCents=$amountCents) ' '→ ${r.status}', ); check( r.status == 201 && r.json['code'] == 0, '健康事件创建成功', '健康事件创建失败: ${r.status} ${r.body}', ); final healthEvent = r.json['data'] as Map; final healthEventId = healthEvent['id'] as String; check( healthEvent['amountCents'] == amountCents && healthEvent['createdByUserId'] == userIdA, 'amountCents=$amountCents 原样回读,createdByUserId=token subject', '健康事件字段不符: ${r.body}', ); print(' healthEventId: $healthEventId'); r = await call( 'GET', '$petUrl/api/v1/pets/$petId/health-events', token: tokenA, ); print(' GET /health-events → ${r.status}'); final timeline = (r.json['data'] as Map)['items'] as List; check( r.status == 200 && timeline.length == 1 && (timeline.first as Map)['id'] == healthEventId && (timeline.first as Map)['title'] == 'M2 烟囱体检', '时间线读回 1 条且字段一致', '时间线读回不符: ${r.body}', ); _passed++; print(''); // ================================================================ // [6/11] 创建提醒 → 标记完成(completedAt 校验) // ================================================================ print('[6/11] 创建提醒 → 标记完成(completedAt 校验)'); final dueAt = now.add(const Duration(days: 30)).toIso8601String(); r = await call( 'POST', '$petUrl/api/v1/pets/$petId/care-reminders', token: tokenA, body: {'reminderType': 'deworming', 'title': '季度驱虫', 'dueAt': dueAt}, ); print(' POST /care-reminders (deworming, dueAt=$dueAt) → ${r.status}'); final reminder = r.json['data'] as Map; check( r.status == 201 && reminder['status'] == 'pending' && reminder['completedAt'] == null, '提醒创建成功,恒为 pending 且 completedAt=null', '提醒创建不符: ${r.status} ${r.body}', ); final reminderId = reminder['id'] as String; print(' reminderId: $reminderId'); final completedAt = now.toIso8601String(); r = await call( 'PATCH', '$petUrl/api/v1/care-reminders/$reminderId', token: tokenA, body: {'status': 'completed', 'completedAt': completedAt}, ); print(' PATCH /care-reminders/$reminderId (→completed) → ${r.status}'); final reminderDone = r.json['data'] as Map; check( r.status == 200 && reminderDone['status'] == 'completed' && reminderDone['completedAt'] != null, '标记完成成功,completedAt=${reminderDone['completedAt']}(客户端提交时刻回读)', '提醒标记完成不符: ${r.body}', ); _passed++; print(''); // ================================================================ // [7/11] 摘要核对:四项聚合逐项断言 // ================================================================ print('[7/11] GET /summary?tz=Asia/Shanghai 四项聚合逐项断言'); r = await call( 'GET', '$petUrl/api/v1/pets/$petId/summary?tz=Asia/Shanghai', token: tokenA, ); print(' GET /summary → ${r.status}'); check( r.status == 200 && r.json['code'] == 0, '摘要返回 200', '摘要读取失败: ${r.status} ${r.body}', ); final summary = r.json['data'] as Map; final latestWeight = summary['latestWeight'] as Map?; check( latestWeight != null && latestWeight['weightKg'] == 8.45, '最新体重 = 8.45kg(第二条写入,measured_at DESC 首行)', 'latestWeight 不符: $latestWeight', ); final progress = summary['vaccinationProgress'] as Map?; check( progress != null && progress['completedDoses'] == 1 && progress['totalDoses'] == 1, '疫苗进度 = 1/1(scheduled→completed 后)', 'vaccinationProgress 不符: $progress', ); final nextVacc = summary['nextVaccination'] as Map?; check( nextVacc != null && nextVacc['vaccinationId'] == vaccinationId && nextVacc['dueOn'] == nextDue && nextVacc['source'] == 'nextDue', '下次接种 = completed 行的 nextDueOn($nextDue,source=nextDue)', 'nextVaccination 不符: $nextVacc', ); final expense = summary['monthlyExpense'] as Map; final shanghaiNow = now.add(const Duration(hours: 8)); final expectMonth = '${shanghaiNow.year}-' '${shanghaiNow.month.toString().padLeft(2, '0')}'; check( expense['amountCents'] == amountCents && expense['month'] == expectMonth && expense['timezone'] == 'Asia/Shanghai', '当月花费 = $amountCents 分,month=$expectMonth,timezone 回显 Asia/Shanghai', 'monthlyExpense 不符: $expense', ); _passed++; print(''); // ================================================================ // [8/11] 权限拒绝:账号 B 访问 A 的宠物四路 → 404/40401 响应体一致 // ================================================================ print('[8/11] 注册账号 B → 用 B 的 token 访问 A 的宠物四路(防枚举核对)'); 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.json['code'] == 0, '账号 B 注册成功', 'B 注册失败: ${r.body}', ); final tokenB = (r.json['data'] as Map)['accessToken'] as String; print(' accessToken(B): ${redact(tokenB)}'); final deniedRoutes = { '详情 GET /pets/{id}': '$petUrl/api/v1/pets/$petId', '体重 GET /pets/{id}/weights': '$petUrl/api/v1/pets/$petId/weights', '疫苗 GET /pets/{id}/vaccinations': '$petUrl/api/v1/pets/$petId/vaccinations', '摘要 GET /pets/{id}/summary': '$petUrl/api/v1/pets/$petId/summary', }; final deniedBodies = []; for (final entry in deniedRoutes.entries) { r = await call('GET', entry.value, token: tokenB); print(' ${entry.key} → ${r.status} / code ${r.json['code']}'); check( r.status == 404 && r.json['code'] == 40401, '404/40401(${entry.key})', '${entry.key} 未按防枚举拒绝: ${r.body}', ); deniedBodies.add(r.body); } check( deniedBodies.toSet().length == 1, '四路响应体完全一致(防枚举):${deniedBodies.first}', '四路响应体不一致: $deniedBodies', ); r = await call('GET', '$petUrl/api/v1/pets', token: tokenB); check( r.status == 200 && (r.json['data'] as List).isEmpty, 'B 的宠物列表为空(列表天然隔离)', 'B 列表泄露: ${r.body}', ); _passed++; print(''); // ================================================================ // [9/11] 跨设备读取:账号 A 重新登录(新会话)→ 全量数据读回 // ================================================================ print('[9/11] 账号 A 重新登录(模拟第二设备新会话)→ 全量数据读回'); r = await call( 'POST', '$authUrl/api/v1/auth/login', body: {'username': usernameA, 'password': password}, ); print(' POST /api/v1/auth/login (设备 2) → ${r.status}'); check(r.status == 200, '第二设备登录成功', '第二设备登录失败: ${r.body}'); final tokenA2 = (r.json['data'] as Map)['accessToken'] as String; check( tokenA2 != tokenA, '新会话 token 与设备 1 不同(独立 token family)', '两次登录 token 相同', ); print(' accessToken(设备2): ${redact(tokenA2)}'); r = await call('GET', '$petUrl/api/v1/pets', token: tokenA2); check( r.status == 200 && (r.json['data'] as List).length == 1 && ((r.json['data'] as List).first as Map)['name'] == '旺财M2', '宠物列表:1 只(旺财M2)', '设备 2 宠物列表不符: ${r.body}', ); r = await call('GET', '$petUrl/api/v1/pets/$petId/weights', token: tokenA2); check( ((r.json['data'] as Map)['items'] as List).length == 2, '体重记录:2 条', '设备 2 体重不符: ${r.body}', ); r = await call( 'GET', '$petUrl/api/v1/pets/$petId/vaccinations', token: tokenA2, ); final vaccList = r.json['data'] as List; check( vaccList.length == 1 && (vaccList.first as Map)['status'] == 'completed', '疫苗记录:1 条(completed)', '设备 2 疫苗不符: ${r.body}', ); r = await call( 'GET', '$petUrl/api/v1/pets/$petId/health-events', token: tokenA2, ); check( ((r.json['data'] as Map)['items'] as List).length == 1, '健康事件:1 条', '设备 2 健康事件不符: ${r.body}', ); r = await call( 'GET', '$petUrl/api/v1/pets/$petId/care-reminders', token: tokenA2, ); final remList = r.json['data'] as List; check( remList.length == 1 && (remList.first as Map)['status'] == 'completed', '提醒:1 条(completed,completedAt=${(remList.first as Map)['completedAt']})', '设备 2 提醒不符: ${r.body}', ); _passed++; print(''); // ================================================================ // [10/11] 埋点链路:POST /api/v1/events 上报 v2 事件 → 202 逐条 accepted // ================================================================ print('[10/11] POST /api/v1/events 上报 v2 事件(platform=android 模拟真机值)'); final anonymousId = uuidV4(); final sessionId = uuidV4(); final clientTs = DateTime.now().toUtc().toIso8601String(); Map baseEvent(String name, Map props) => { 'eventId': uuidV4(), 'eventName': name, 'eventVersion': 2, 'anonymousId': anonymousId, 'userId': userIdA, 'sessionId': sessionId, 'clientTs': clientTs, 'appVersion': '1.0.0+e2e', 'platform': 'android', 'osVersion': 'android-14', 'props': props, }; final events = [ baseEvent('pet_create_succeeded', { 'durationMs': 1200, 'species': 'dog', 'petIndex': 1, }), baseEvent('health_record_create_succeeded', { 'recordType': 'weight', 'durationMs': 640, }), baseEvent('health_record_create_succeeded', { 'recordType': 'vaccine', 'durationMs': 820, }), baseEvent('page_viewed', { 'pageName': 'pet_detail', 'referrer': 'pet_list', }), ]; for (final e in events) { print(' eventId: ${e['eventId']} (${e['eventName']})'); } r = await call( 'POST', '$userUrl/api/v1/events', token: tokenA2, body: {'events': events}, ); print(' POST /api/v1/events (4 条) → ${r.status}'); check( r.status == 202 && r.json['code'] == 0, '批次受理 202', '埋点上报失败: ${r.status} ${r.body}', ); final trackData = r.json['data'] as Map; final results = trackData['results'] as List; final allAccepted = results.every( (e) => (e as Map)['status'] == 'accepted', ); check( trackData['accepted'] == 4 && trackData['rejected'] == 0 && results.length == 4 && allAccepted, '4/4 逐条 accepted(accepted=4, duplicated=0, rejected=0)', '埋点结果不符: ${r.body}', ); print(' 落库核对(platform.product_events)由报告附 psql 证据。'); print(' E2E_SESSION_ID=$sessionId'); // 供 psql 查证 _passed++; print(''); // ================================================================ // [11/11] 乐观锁冲突明确性:两次 PATCH 同一 version → 40902 // ================================================================ print('[11/11] 两次 PATCH 宠物档案提交同一 version → 第二次 409/40902'); r = await call( 'PATCH', '$petUrl/api/v1/pets/$petId', token: tokenA, body: {'version': petVersion, 'personality': '沉稳'}, ); print(' PATCH /pets/$petId (version=$petVersion, 第一次) → ${r.status}'); check( r.status == 200 && (r.json['data'] as Map)['version'] == petVersion + 1, '第一次 PATCH 成功,version $petVersion→${petVersion + 1}', '第一次 PATCH 失败: ${r.body}', ); r = await call( 'PATCH', '$petUrl/api/v1/pets/$petId', token: tokenA2, body: {'version': petVersion, 'personality': '黏人'}, ); print( ' PATCH /pets/$petId (同一过期 version=$petVersion, 第二次/设备 2) ' '→ ${r.status}', ); check( r.status == 409 && r.json['code'] == 40902, '第二次被明确拒绝:409/40902(${r.json['message']}),先写者数据保留', '乐观锁冲突语义不符: ${r.status} ${r.body}', ); r = await call('GET', '$petUrl/api/v1/pets/$petId', token: tokenA); check( (r.json['data'] as Map)['personality'] == '沉稳', '读回确认先写者数据保留(personality=沉稳)', '并发覆盖发生: ${r.body}', ); _passed++; print(''); print('=== M2 E2E 烟囱测试全部通过 ✓($_passed/11 场景)==='); print('E2E_USERNAME_A=$usernameA'); print('E2E_PET_ID=$petId'); } catch (e, stack) { print('✗ 测试异常: $e'); print(stack); exit(1); } finally { client.close(); } }