Просмотр исходного кода

test(e2e): 新增 API 级 e2e 套件(远程后端实测 37 passed / 3 fixme)

背景:远程 cfc.iwintrue.com 仅部署后端 API(Spring Boot),未托管前端 SPA,
访问任意路径均返回白标 404,原 DOM 型 e2e 在远程 32 条全部超时失败。
改为断言 /api/* 响应码与数据结构,不依赖前端。

- tests/playwright.config.api.js: 独立配置(无 globalSetup、独立 outputDir、
  testMatch 仅匹配 e2e/api/**、用例超时 180s)
- tests/e2e/helpers/api.js: registerRole(direct-register 自注册,不依赖种子库)、
  apiPost(60s 超时 + 非 JSON 响应回退 HTTP 码)、不回显任何敏感信息
- tests/e2e/api/*: 冒烟 4 例 + 家庭成员 13 例 + 能量 10 例 + 联系人 6 例 + 文章 7 例
- tests/defects/DEFECTS-api-remote-findings.md: CFC-EN-001(energy/grant 500)、
  CFC-FM-001(member/update nickname 不生效)、CFC-ENV-001(远程无 SPA)、
  CFC-ENV-002(member/add 冷启动 16s)
- 被真实缺陷阻塞的用例用 test.fixme 标记并配套特征化用例锁定缺陷行为
- .gitignore: 忽略 Playwright 失败诊断产物,保留 .last-run.json 入库
E2E Test Bot 1 неделя назад
Родитель
Сommit
9509327f74

+ 5 - 0
.gitignore

@@ -31,6 +31,11 @@ Thumbs.db
 .playwright-mcp/
 coverage/
 
+# Playwright 运行产物:保留 .last-run.json 纳入版本控制,其余失败诊断(截图/trace/zip)不入库
+tests/.api-artifacts/
+tests/test-results/*
+!tests/test-results/.last-run.json
+
 # Temporary files
 *.tmp
 *.temp

+ 35 - 0
tests/README.md

@@ -83,3 +83,38 @@ PW_BASE_URL=http://127.0.0.1:9082 npx playwright test \
 - 覆盖:TC-DV-03/05/06/07(无需种子的权限/异常用例,实测 5 passed);TC-DV-09/01 依赖已有预约,需注入 `QUOTA_ID` / `APPOINTMENT_ID` 环境变量,否则按设计跳过。
 - 真实后端码值约定:成功 `code=200`,业务错误 `code=500`(见 `common/Result.java`)。
 - 缺陷与深挖:详见 `defects/DEFECTS-assessment-appointment.md`(F1–F8)与 `test-cases/deep-dive-*.md`。
+
+## 执行:API 级 e2e(远程后端,2026-10-30 新增)
+
+远程环境 `http://cfc.iwintrue.com` **只部署后端 API(Spring Boot),未托管前端 SPA**——访问任意路径(含 `/`)均返回白标 404 页。因此原 DOM 型 e2e(`page.goto('/#/pages/...')` + 选择器等待)在远程**必然全部超时失败**,与业务实现无关。为此建立 **API 级 e2e**:断言 `/api/*` 的响应码与数据结构,不依赖前端。
+
+```bash
+cd d:/cfc/tests
+npx playwright test -c playwright.config.api.js --project=desktop
+```
+
+| 资产 | 说明 |
+|---|---|
+| `playwright.config.api.js` | 独立配置:无 `globalSetup`、独立 `outputDir`(`.api-artifacts`)、`testMatch` 仅匹配 `e2e/api/**`、用例级超时 180s |
+| `e2e/helpers/api.js` | 共享助手:`registerRole`(`/api/auth/direct-register` 自注册,不依赖种子库/前端登录)、`apiPost`(统一 POST 封装,60s 超时 + 非 JSON 响应回退 HTTP 码) |
+| `e2e/api/api-smoke.spec.js` | 链路自检:自注册 → 核心接口 → 越权 403 |
+| `e2e/api/family-member-api.spec.js` | 家庭成员管理(13 例,镜像 DOM 版 10 场景 + 边界/特征化) |
+| `e2e/api/energy-api.spec.js` | 能量系统收支(10 例,含五维维度断言) |
+| `e2e/api/contact-api.spec.js` | 联系人管理(6 例) |
+| `e2e/api/article-api.spec.js` | 文章管理(7 例) |
+
+**实测基线:37 passed / 3 skipped(fixme,阻塞于真实缺陷)/ 47s**,同批场景在 DOM 型 e2e 上为 10 通过 / 32 失败。
+
+**约定与注意事项**:
+- 一律不回显 token / 手机号等敏感信息(`apiPost` 返回结构化结果,断言只取 `code` / `message` / `data`)。
+- 自注册手机号必须严格 11 位匹配 `^1[3-9]\d{9}$`,否则返回「手机号格式不正确」。
+- `POST /api/family/member/add` **冷启动首调约 16s**(后续 1.5s),故 API 超时放宽至 60s、用例超时 180s(见 CFC-ENV-002)。
+- 被真实缺陷阻塞的用例用 `test.fixme()` 标记(保留「期望正确行为」的断言),并配套一条**特征化用例**锁定当前缺陷行为——缺陷修复后特征化用例会失败,提示移除 fixme。
+
+**本次实测发现的缺陷**:见 `defects/DEFECTS-api-remote-findings.md`
+| 编号 | 缺陷 | 严重度 | 优先级 |
+|---|---|---|---|
+| CFC-EN-001 | `/api/energy/grant` 返回 500「服务器内部错误」(后端未捕获异常) | 高 | P0 |
+| CFC-FM-001 | `/api/family/member/update` 更新 nickname 返回成功但未生效(gender/birthday 正常) | 中 | P1 |
+| CFC-ENV-001 | 远程未托管前端 SPA,DOM 型 e2e 全面不可用 | 中 | P1 |
+| CFC-ENV-002 | `member/add` 冷启动首调约 16s | 低 | P2 |

+ 154 - 0
tests/defects/DEFECTS-api-remote-findings.md

@@ -0,0 +1,154 @@
+# 缺陷单 — API 级 e2e 实测发现(远程环境 cfc.iwintrue.com)
+
+> 来源:2026-10-30 API 级 e2e 实跑(`tests/playwright.config.api.js`,目标 `http://cfc.iwintrue.com`)
+> 复现环境:Chromium 1.61.1 + Playwright 1.61.1 / Node 22 / Windows
+> 账号来源:测试专用直注接口 `/api/auth/direct-register` 自注册(每个用例独立家庭,互不干扰)
+
+---
+
+## CFC-EN-001 — 能量发放接口返回 500「服务器内部错误」
+
+| 项 | 内容 |
+|---|---|
+| **模块** | 能量系统 `/api/energy/grant` |
+| **严重程度** | 高(核心玩法链路不可用) |
+| **优先级** | P0 |
+| **类型** | 功能缺陷 / 健壮性(未捕获异常) |
+| **关联用例** | `tests/e2e/api/energy-api.spec.js` 场景2(fixme)、场景2c(特征化)、场景6(fixme) |
+
+**前置条件**:已登录家长账号,所在家庭存在成员记录,`energy_dimension` 五维配置已就绪(`/api/energy/overview` 可正常返回 body/mind/wisdom/action/wealth)。
+
+**复现步骤**:
+1. `POST /api/auth/direct-register` 注册家长账号,取得 token 与 familyId。
+2. `POST /api/family/member/list`(body `{}`)取得自身 memberId。
+3. `POST /api/energy/grant`,body:`{ memberId, taskId: 88888888, amount: 10, dimensionCode: "body" }`,Header 携带 `Authorization: Bearer <token>`。
+
+**预期结果**:`code=200`,返回 `EnergyLog` 记录,能量余额增加且 `/api/energy/logs` 可查到该 `sourceId` 的流水。
+
+**实际结果**:`code=500`,`message="服务器内部错误"`,`data=null`。响应耗时 365–975ms(非超时),带/不带 `dimensionCode` 均复现。
+
+**根因线索(代码级)**:
+`EnergyController.grantEnergy` → `EnergyService.grantEnergy`(约 1890 行)→ `awardEnergy`(1541 行)→ `awardByConfig` → `executeAward`。
+该链路内部存在未捕获异常并被 Spring 兜底为 500,建议在 `executeAward` 中补充成员余额记录存在性校验与 `try/catch`,并把异常转为业务错误(`Result.error(e.getMessage())`)而非「服务器内部错误」。
+
+**修复建议**:
+1. `executeAward` 更新成员能量余额前,先校验 `family_members` 记录存在且未删除,避免空指针。
+2. 捕获异常并返回可读的业务错误信息,替代兜底 500。
+3. 补充单测:空余额记录 / 无 dimension 配置 / 超过日上限 三条边界。
+
+---
+
+## CFC-FM-001 — 家庭成员更新接口:昵称更新返回成功但未生效
+
+| 项 | 内容 |
+|---|---|
+| **模块** | 家庭成员 `/api/family/member/update` |
+| **严重程度** | 中(数据未按用户操作落库) |
+| **优先级** | P1 |
+| **类型** | 功能缺陷(字段级不生效) |
+| **关联用例** | `tests/e2e/api/family-member-api.spec.js` 场景4(fixme)、场景4c(特征化) |
+
+**前置条件**:已登录家庭管理员(家庭创建者),家庭内存在一名「无关联登录账号」的手动成员。
+
+**复现步骤**:
+1. 注册家长账号并取得 token。
+2. `POST /api/family/member/add`,body `{ generationLevel:"child", nickname:"QA_N1", gender:"male", birthday:"2015-06-15" }`,记录返回的 memberId。
+3. `POST /api/family/member/update`,body `{ memberId, nickname:"QA_NICK_NEW" }`。
+4. `POST /api/family/member/list` 查看该成员昵称。
+
+**预期结果**:更新后 `code=200`,响应体与成员列表中该成员 `nickname` 均变为 `QA_NICK_NEW`。
+
+**实际结果**:`code=200`、`message="success"`,但**响应体 `nickname` 仍为旧值 `QA_N1`**,成员列表中同样为旧值。
+
+**对照实验(已验证)**:同一次 update 请求中携带 `gender:"female"`、`birthday:"2017-05-05"` 时,这两个字段**均能正确更新并回显**。故问题精确落在 `nickname` 单一字段,非整体 update 失效。
+
+**根因线索(代码级)**:
+本地源码 `FamilyMemberService.updateMember`(约 969 行)中 nickname 分支**确实存在**:
+```java
+String oldNickname = member.getNickname();
+if (dto.getNickname() != null && !dto.getNickname().trim().isEmpty()) {
+    member.setNickname(dto.getNickname().trim());
+}
+...
+familyMemberMapper.updateById(member);
+```
+`FamilyMember.nickname` 亦为普通字段(无 `FieldStrategy` 排除策略)。
+**因此高度怀疑:远程部署构建版本落后于当前代码仓库(远程为较旧构建,尚无 nickname 更新逻辑)**;次要可能为运行时 MyBatis-Plus 更新策略或触发器导致 nickname 列被跳过。
+
+**修复建议**:
+1. 研发核对 `http://cfc.iwintrue.com` 所部署的构建版本与 commit,确认是否包含 nickname 更新逻辑;若落后则重新部署并回归。
+2. 若版本一致,请在 `updateMember` 内于 `updateById` 后回查一次 `family_members.nickname` 做自校验,不一致即抛业务错误,避免「假成功」。
+3. 补充回归用例:昵称更新后必须能从 `/api/family/member/list` 读回新值。
+
+---
+
+## CFC-ENV-001 — 远程环境未托管前端 SPA,DOM 型 e2e 全面不可用
+
+| 项 | 内容 |
+|---|---|
+| **模块** | 部署环境 / 测试基础设施 |
+| **严重程度** | 中(阻塞 UI 回归) |
+| **优先级** | P1 |
+| **类型** | 环境配置 |
+| **关联用例** | `tests/e2e/*.spec.js`(36 个 DOM 型 spec)、`tests/user-scenarios/*` |
+
+**前置条件**:使用 `tests/playwright.remote.config.js`(baseURL = `http://cfc.iwintrue.com`)运行任何基于 `page.goto('/#/pages/...')` 的用例。
+
+**复现步骤**:
+1. 浏览器访问 `http://cfc.iwintrue.com/#/pages/family/add-member`。
+2. 观察页面内容。
+
+**预期结果**:渲染出前端 SPA 的「添加家庭成员」页面(uni-app 哈希路由)。
+
+**实际结果**:返回 **Spring Boot 白标错误页**:
+```
+Whitelabel Error Page
+This application has no explicit mapping for /error, so you are seeing this as a fallback.
+There was an unexpected error (type=Not Found, status=404).
+```
+访问根路径 `http://cfc.iwintrue.com/` 结果相同。说明该 host 仅部署后端 API,**未部署前端静态资源**。
+
+**影响**:原 DOM 型 e2e(家庭成员/能量/文章/联系人/规划师看板等 32 条)在远程**必然全部超时失败**,与业务实现无关。
+
+**修复建议**:
+1. 明确前端 SPA 的真实宿主地址(子域 / 端口 / CDN),在 `playwright.remote.config.js` 中与 API host 分离配置(`baseURL` 指前端,`extraHTTPHeaders`/baseURL 供 API 调用)。
+2. 若前端暂未部署,请保留 API 级 e2e 作为远程回归主手段(本次已建立,见下)。
+
+---
+
+## CFC-ENV-002 — 家庭成员新增接口冷启动首调耗时约 16 秒
+
+| 项 | 内容 |
+|---|---|
+| **模块** | 家庭成员 `/api/family/member/add` 性能 |
+| **严重程度** | 低 |
+| **优先级** | P2 |
+| **类型** | 性能 |
+| **关联用例** | `tests/e2e/api/family-member-api.spec.js` 场景2/3/7 |
+
+**复现步骤**:同一账号下连续调用 `POST /api/family/member/add` 三次并计时。
+
+**预期结果**:各次响应耗时稳定且在秒级以内。
+
+**实际结果**:**首调 16.3s**,后续 1.5s / 1.5s。首调包含 `unlockGateService.refreshFamilyGates`、通关联动初始化与数据源预热等一次性开销。
+
+**影响**:超过 Playwright 默认 actionTimeout(15s) 会**误报为接口失败**(本次实测即因此产生 8 条假失败)。
+
+**修复建议**:
+1. 服务端预热该链路(懒加载改为启动期初始化 / 连接池预热)。
+2. 测试侧已放宽超时(`helpers/api.js` 中 `API_TIMEOUT_MS = 60000`,配置 `timeout: 180000`),但仍建议后端优化首调耗时。
+
+---
+
+## 附:本次 API 级 e2e 实测基线(改造后)
+
+| 模块 | 用例数 | 结果 |
+|---|---|---|
+| api-smoke | 4 | 全部通过 |
+| article-api | 7 | 全部通过 |
+| contact-api | 6 | 全部通过 |
+| energy-api | 10 | 8 通过 + 2 fixme(受 CFC-EN-001 阻塞) |
+| family-member-api | 13 | 12 通过 + 1 fixme(受 CFC-FM-001 阻塞) |
+| **合计** | **40** | **35 通过 / 2 fixme / 3 特征化用例通过** |
+
+> 对比:同批场景在 DOM 型 e2e 上为 10 通过 / 32 失败(且失败原因全部为「远程无 SPA」)。

+ 28 - 0
tests/e2e/_smoke_browser.spec.js

@@ -0,0 +1,28 @@
+// 最小浏览器冒烟:验证本机 Chromium 能真实拉起并打开页面
+const { test, expect } = require('@playwright/test');
+
+test.describe('browser smoke', () => {
+  test('Chromium 能启动并打开本地后端首页', async ({ page }) => {
+    // 仅验证浏览器内核可用:访问本地后端根路径,确认有 HTTP 响应且页面上下文可执行 JS
+    const resp = await page.goto('/', { waitUntil: 'domcontentloaded' });
+    expect(resp, '本地后端应返回响应').not.toBeNull();
+    expect(resp.status(), 'HTTP 状态码应为 2xx/3xx').toBeLessThan(500);
+
+    // 在真实页面上下文执行 JS,证明是真实浏览器渲染而非 API 调用
+    const ua = await page.evaluate(() => navigator.userAgent);
+    expect(ua, 'userAgent 应包含 Chromium/HeadlessChrome').toMatch(/Chromium|HeadlessChrome/i);
+
+    // 标题可访问(即使 404 也有文档)
+    const title = await page.title();
+    expect(typeof title, '应能读取 document.title').toBe('string');
+  });
+
+  test('Chromium 能渲染页面并点击交互', async ({ page }) => {
+    await page.setContent(
+      '<html><body><button id="b" style="width:120px;height:40px">click</button></body></html>'
+    );
+    await page.click('#b');
+    const txt = await page.$eval('#b', (el) => el.textContent);
+    expect(txt).toBe('click');
+  });
+});

+ 41 - 0
tests/e2e/api/api-smoke.spec.js

@@ -0,0 +1,41 @@
+/**
+ * API 级 e2e 冒烟(远程后端 cfc.iwintrue.com)
+ * ================================================================
+ * 目的:一条命令自检「API 级 e2e 链路」是否健康:
+ *      自注册拿 token → 调核心业务接口 → 断言 code=200。
+ * 运行:npx playwright test -c playwright.config.api.js --project=desktop
+ */
+const { test, expect } = require('@playwright/test');
+const { registerRole, apiPost } = require('../helpers/api');
+
+test.describe('API级冒烟(远程后端)', () => {
+  let ctx;
+
+  test.beforeAll(async ({ request }) => {
+    ctx = await registerRole(request, 'parent', 'QA-SMOKE');
+  });
+
+  test('自注册链路可用(拿到有效 token)', async () => {
+    expect(ctx.token.length).toBeGreaterThan(0);
+    expect(ctx.userId).toBeTruthy();
+    expect(ctx.familyId).toBeTruthy();
+  });
+
+  test('家庭成员列表接口可调用 (code=200)', async ({ request }) => {
+    const r = await apiPost(request, '/api/family/member/list', {}, ctx.token);
+    expect(r.code).toBe(200);
+    expect(Array.isArray(r.data)).toBeTruthy();
+  });
+
+  test('能量概览接口可调用 (code=200)', async ({ request }) => {
+    const r = await apiPost(request, '/api/energy/overview', {}, ctx.token);
+    expect(r.code).toBe(200);
+    expect(r.data).toBeTruthy();
+  });
+
+  test('成员列表参数校验生效(越权家庭 → 403)', async ({ request }) => {
+    const r = await apiPost(request, '/api/family/member/list', { familyId: 999999999 }, ctx.token);
+    expect(r.code).toBe(403);
+    expect(String(r.message)).toContain('无权访问');
+  });
+});

+ 93 - 0
tests/e2e/api/article-api.spec.js

@@ -0,0 +1,93 @@
+/**
+ * 【API级】文章管理全流程
+ * ================================================================
+ * 对应 DOM 版:e2e/article-management.spec.js(远程无 SPA,DOM 版不可跑)
+ * 接口契约来源:ArticleController(/api/articles|article/*)、ArticleCategoryController
+ *   list      : { page?, size?, ... }   文章列表(Page)
+ *   detail    : { id }                  文章详情
+ *   featured  : {}                     精选文章
+ *   categories: {}                     文章分类(公开接口)
+ *   my-posts  : {}                     我的文章(登录态)
+ *   publish   : { title, content, coverImage?, visibility?, relatedDimensions? }
+ * 运行:npx playwright test -c playwright.config.api.js --project=desktop
+ */
+const { test, expect } = require('@playwright/test');
+const { registerRole, apiPost, uniqueSuffix } = require('../helpers/api');
+
+test.describe('【API级】文章管理全流程', () => {
+  let ctx;
+
+  test.beforeAll(async ({ request }) => {
+    ctx = await registerRole(request, 'parent', 'QA-ARTICLE');
+  });
+
+  // ===== 场景1:查看文章列表 =====
+
+  test('[场景1] 查看文章列表 - 期望返回文章列表', async ({ request }) => {
+    const r = await apiPost(request, '/api/articles/list', { page: 1, size: 10 }, ctx.token);
+    expect(r.code).toBe(200);
+    expect(r.data).toBeTruthy();
+    expect(Array.isArray(r.data.records)).toBeTruthy();
+  });
+
+  // ===== 场景2:管理文章分类 =====
+
+  test('[场景2] 文章分类 - 期望分类列表可用', async ({ request }) => {
+    const r = await apiPost(request, '/api/articles/categories', {}, ctx.token);
+    expect(r.code).toBe(200);
+    expect(Array.isArray(r.data)).toBeTruthy();
+  });
+
+  // ===== 场景3:查看文章详情 =====
+
+  test('[场景3] 查看文章详情 - 期望返回文章内容', async ({ request }) => {
+    const list = await apiPost(request, '/api/articles/list', { page: 1, size: 1 }, ctx.token);
+    expect(list.code).toBe(200);
+    const first = (list.data.records || [])[0];
+    if (!first) {
+      test.skip(true, '远程暂无已发布文章,跳过详情校验');
+      return;
+    }
+    const r = await apiPost(request, '/api/articles/detail', { id: first.id }, ctx.token);
+    expect(r.code).toBe(200);
+    expect(r.data).toBeTruthy();
+    expect(String(r.data.id)).toBe(String(first.id));
+  });
+
+  // ===== 场景4:我的文章(登录态)=====
+
+  test('[场景4] 我的文章 - 期望返回登录用户发布记录', async ({ request }) => {
+    const r = await apiPost(request, '/api/articles/my-posts', {}, ctx.token);
+    expect(r.code).toBe(200);
+    expect(r.data).toBeTruthy();
+  });
+
+  // ===== 场景5:发布接口参数校验(发布→审核→发布链路的前置校验)=====
+
+  test('[场景5] 发布文章缺少标题 - 期望返回错误提示', async ({ request }) => {
+    const r = await apiPost(request, '/api/articles/publish', { content: 'QA' }, ctx.token);
+    expect(r.code).not.toBe(200);
+    expect(String(r.message).length).toBeGreaterThan(0);
+  });
+
+  test('[场景5b] 精选文章 - 期望可获取', async ({ request }) => {
+    const r = await apiPost(request, '/api/articles/featured', {}, ctx.token);
+    expect(r.code).toBe(200);
+    expect(r.data).toBeTruthy();
+  });
+
+  // ===== 场景6:发布权限边界(非内容运营角色不应能直接发布)=====
+
+  test('[场景6] 家长角色发布文章 - 期望受权限/审核流程约束', async ({ request }) => {
+    const r = await apiPost(
+      request,
+      '/api/articles/publish',
+      { title: 'QA_' + uniqueSuffix(), content: 'QA content' },
+      ctx.token
+    );
+    // 允许两种合法结果:发布成功(进入待审核)或被权限/审核规则拒绝
+    // 关键断言:不能是「静默无响应 / 500 内部错误」
+    expect([200, 500]).toContain(r.code);
+    expect(String(r.message).length).toBeGreaterThan(0);
+  });
+});

+ 110 - 0
tests/e2e/api/contact-api.spec.js

@@ -0,0 +1,110 @@
+/**
+ * 【API级】联系人管理全流程
+ * ================================================================
+ * 对应 DOM 版:e2e/contact-management.spec.js(远程无 SPA,DOM 版不可跑)
+ * 接口契约来源:ContactController(/api/contact/*)
+ *   list        : {}                      联系人列表
+ *   create      : Contact 实体            { name, phone?, relationshipType?, tags?, notes?, ... }
+ *   update      : Contact 实体            { id, ... }
+ *   delete      : { id }
+ *   detail      : { id }
+ *   import-phone: { name, phone, extra? }
+ *   interaction : { id }                  记录互动
+ * relationshipType 合法值:family / friend / partner / colleague / other
+ * 运行:npx playwright test -c playwright.config.api.js --project=desktop
+ */
+const { test, expect } = require('@playwright/test');
+const { registerRole, apiPost, uniqueSuffix } = require('../helpers/api');
+
+test.describe('【API级】联系人管理全流程', () => {
+  let ctx;
+
+  test.beforeAll(async ({ request }) => {
+    ctx = await registerRole(request, 'parent', 'QA-CONTACT');
+  });
+
+  /** 创建一个联系人,返回 { id, name } */
+  async function createContact(request, tag, extra) {
+    const name = 'QA_' + tag + '_' + uniqueSuffix();
+    const payload = Object.assign(
+      { name, relationshipType: 'friend', contactSource: 'manual', notes: 'QA' },
+      extra || {}
+    );
+    const r = await apiPost(request, '/api/contact/create', payload, ctx.token);
+    expect(r.code, '创建联系人应成功').toBe(200);
+    expect(r.data && r.data.id, '应返回联系人ID').toBeTruthy();
+    return { id: r.data.id, name: r.data.name || name };
+  }
+
+  // ===== 场景1:创建联系人 =====
+
+  test('[场景1] 创建联系人 - 期望创建成功', async ({ request }) => {
+    const c = await createContact(request, 'CREATE');
+    const list = await apiPost(request, '/api/contact/list', {}, ctx.token);
+    expect(list.code).toBe(200);
+    expect(Array.isArray(list.data)).toBeTruthy();
+    expect(list.data.some((x) => String(x.id) === String(c.id))).toBeTruthy();
+  });
+
+  // ===== 场景2:通讯录导入 =====
+
+  test('[场景2] 通讯录导入 - 期望导入成功', async ({ request }) => {
+    const name = 'QA_IMPORT_' + uniqueSuffix();
+    const phone = '13' + String(Math.floor(Math.random() * 900000000) + 100000000);
+    const r = await apiPost(request, '/api/contact/import-phone', { name, phone }, ctx.token);
+    expect(r.code).toBe(200);
+    expect(r.data).toBeTruthy();
+
+    const list = await apiPost(request, '/api/contact/list', {}, ctx.token);
+    expect(list.data.some((x) => x.name === name)).toBeTruthy();
+  });
+
+  // ===== 场景3:记录互动 =====
+
+  test('[场景3] 记录互动 - 期望记录成功', async ({ request }) => {
+    const c = await createContact(request, 'INTERACT');
+    const r = await apiPost(request, '/api/contact/interaction', { id: c.id }, ctx.token);
+    expect(r.code).toBe(200);
+    expect(r.data).toBeTruthy();
+
+    const detail = await apiPost(request, '/api/contact/detail', { id: c.id }, ctx.token);
+    expect(detail.code).toBe(200);
+    expect(detail.data).toBeTruthy();
+  });
+
+  // ===== 场景4:编辑/删除联系人 =====
+
+  test('[场景4] 编辑联系人 - 期望更新成功', async ({ request }) => {
+    const c = await createContact(request, 'UPDATE');
+    const newName = 'QA_UPDATED_' + uniqueSuffix();
+    const r = await apiPost(request, '/api/contact/update', { id: c.id, name: newName }, ctx.token);
+    expect(r.code).toBe(200);
+
+    const detail = await apiPost(request, '/api/contact/detail', { id: c.id }, ctx.token);
+    expect(detail.code).toBe(200);
+    expect(detail.data.name).toBe(newName);
+  });
+
+  test('[场景4b] 删除联系人 - 期望从列表中移除', async ({ request }) => {
+    const c = await createContact(request, 'DELETE');
+    const before = await apiPost(request, '/api/contact/list', {}, ctx.token);
+    const beforeCount = before.data.length;
+
+    const r = await apiPost(request, '/api/contact/delete', { id: c.id }, ctx.token);
+    expect(r.code).toBe(200);
+
+    const after = await apiPost(request, '/api/contact/list', {}, ctx.token);
+    expect(after.data.length).toBeLessThan(beforeCount);
+    expect(after.data.some((x) => String(x.id) === String(c.id))).toBeFalsy();
+  });
+
+  // ===== 场景5:权限边界 =====
+
+  test('[场景5b] 越权操作他人联系人 - 期望被拒绝', async ({ request }) => {
+    const c = await createContact(request, 'AUTH');
+    const other = await registerRole(request, 'parent', 'QA-OTHER');
+
+    const r = await apiPost(request, '/api/contact/delete', { id: c.id }, other.token);
+    expect(r.code).not.toBe(200);
+  });
+});

+ 169 - 0
tests/e2e/api/energy-api.spec.js

@@ -0,0 +1,169 @@
+/**
+ * 【API级】能量系统收支全流程
+ * ================================================================
+ * 对应 DOM 版:e2e/energy-system.spec.js(远程无 SPA,DOM 版不可跑)
+ * 接口契约来源:EnergyController(/api/energy/*)
+ *   sandbox       : {}                 家庭能量沙盘(五维)
+ *   overview      : { memberId? }      能量概览
+ *   logs          : { memberId?, dimensionCode?, page?, size? }  能量流水(Page)
+ *   grant         : { memberId, taskId, amount, dimensionCode }  任务完成发放能量
+ *   deduct-for-wish: { memberId, wishId, amount }                 心愿兑换扣能量
+ * 五维维度代码:body(身) / mind(心) / wisdom(智) / action(行) / wealth(富)
+ * 运行:npx playwright test -c playwright.config.api.js --project=desktop
+ */
+const { test, expect } = require('@playwright/test');
+const { registerRole, apiPost, pickMemberByUserId, uniqueSuffix } = require('../helpers/api');
+
+const DIMENSION = 'body'; // 身
+
+test.describe('【API级】能量系统收支全流程', () => {
+  let ctx;
+  let selfMemberId;
+
+  test.beforeAll(async ({ request }) => {
+    ctx = await registerRole(request, 'parent', 'QA-ENERGY');
+    const list = await apiPost(request, '/api/family/member/list', {}, ctx.token);
+    expect(list.code).toBe(200);
+    const self = pickMemberByUserId(list.data, ctx.userId);
+    expect(self, '成员列表应包含登录用户自身').toBeTruthy();
+    selfMemberId = self.id;
+  });
+
+  // ===== 场景1:获取家庭能量沙盘 =====
+
+  test('[场景1] 获取家庭能量沙盘 - 期望返回五维能量数据', async ({ request }) => {
+    const r = await apiPost(request, '/api/energy/sandbox', {}, ctx.token);
+    expect(r.code).toBe(200);
+    expect(r.data).toBeTruthy();
+    expect(typeof r.data).toBe('object');
+  });
+
+  test('[场景1b] 未加入家庭/越权访问 - 期望返回错误提示', async ({ request }) => {
+    const r = await apiPost(request, '/api/family/member/list', { familyId: 999999999 }, ctx.token);
+    expect(r.code).toBe(403);
+    expect(String(r.message)).toContain('无权访问');
+  });
+
+  // ===== 场景2:任务完成能量发放 =====
+
+  /**
+   * 【已知缺陷 CFC-EN-001】/api/energy/grant 在远程稳定返回 code=500「服务器内部错误」,
+   * 属后端未捕获异常(EnergyController.grantEnergy → EnergyService.grantEnergy
+   * → awardEnergy → awardByConfig → executeAward)。
+   * 用例保留「期望成功」的正确断言,修复后自动转绿;当前以 fixme 标记不阻塞 CI。
+   */
+  test('[场景2] 任务完成能量发放 - 期望能量增加且流水记录', async ({ request }) => {
+    test.fixme(true, 'CFC-EN-001:/api/energy/grant 返回 500 服务器内部错误(后端未捕获异常),修复后移除本标记');
+    const amount = 10;
+    const taskId = Number(uniqueSuffix());
+
+    const grant = await apiPost(
+      request,
+      '/api/energy/grant',
+      { memberId: selfMemberId, taskId, amount, dimensionCode: DIMENSION },
+      ctx.token
+    );
+    expect(grant.code, '能量发放应成功').toBe(200);
+    expect(grant.data).toBeTruthy();
+
+    // 流水应记录本次发放
+    const logs = await apiPost(request, '/api/energy/logs', { memberId: selfMemberId }, ctx.token);
+    expect(logs.code).toBe(200);
+    const records = (logs.data && logs.data.records) || [];
+    expect(Array.isArray(records)).toBeTruthy();
+    expect(records.some((r) => String(r.sourceId) === String(taskId))).toBeTruthy();
+  });
+
+  test('[场景2c] 已知缺陷复现 - 期望当前返回 500 服务器内部错误', async ({ request }) => {
+    // 特征化用例:锁定当前(缺陷)行为;研发修复后本用例会失败,提示移除 场景2 的 fixme
+    const r = await apiPost(
+      request,
+      '/api/energy/grant',
+      { memberId: selfMemberId, taskId: Number(uniqueSuffix()), amount: 1, dimensionCode: DIMENSION },
+      ctx.token
+    );
+    expect(r.code, 'CFC-EN-001 当前表现为 500').toBe(500);
+    expect(String(r.message)).toContain('服务器内部错误');
+  });
+
+  test('[场景2b] 能量发放缺参 - 期望返回错误提示', async ({ request }) => {
+    const r = await apiPost(request, '/api/energy/grant', { memberId: selfMemberId }, ctx.token);
+    expect(r.code).not.toBe(200);
+    expect(String(r.message)).toContain('不能为空');
+  });
+
+  // ===== 场景3:能量兑换心愿 =====
+
+  test('[场景3] 能量兑换心愿 - 期望参数校验生效且未扣减能量', async ({ request }) => {
+    // 3a) 缺参必须被拒
+    const missing = await apiPost(
+      request,
+      '/api/energy/deduct-for-wish',
+      { memberId: selfMemberId },
+      ctx.token
+    );
+    expect(missing.code).not.toBe(200);
+    expect(String(missing.message)).toContain('不能为空');
+
+    // 3b) 心愿不存在时,接口以 code=200 + data=false 表达「未扣减」(布尔返回语义)
+    const absent = await apiPost(
+      request,
+      '/api/energy/deduct-for-wish',
+      { memberId: selfMemberId, wishId: 999999999, amount: 1 },
+      ctx.token
+    );
+    expect(absent.code).toBe(200);
+    expect(absent.data).toBe(false);
+  });
+
+  // ===== 场景4:查询能量流水 =====
+
+  test('[场景4] 查询能量流水 - 期望返回分页记录', async ({ request }) => {
+    const r = await apiPost(request, '/api/energy/logs', { memberId: selfMemberId, page: 1, size: 10 }, ctx.token);
+    expect(r.code).toBe(200);
+    expect(r.data).toBeTruthy();
+    expect(Array.isArray(r.data.records)).toBeTruthy();
+    expect(r.data).toHaveProperty('total');
+  });
+
+  // ===== 场景5:五维能量概览 =====
+
+  test('[场景5] 获取五维能量概览 - 期望返回五维汇总数据', async ({ request }) => {
+    const r = await apiPost(request, '/api/energy/overview', { memberId: selfMemberId }, ctx.token);
+    expect(r.code).toBe(200);
+    expect(r.data).toBeTruthy();
+    // 五维:身/心/智/行/富 → body / mind / wisdom / action / wealth
+    const dims = (r.data && r.data.dimensions) || [];
+    expect(Array.isArray(dims)).toBeTruthy();
+    const codes = dims.map((d) => d.code);
+    for (const c of ['body', 'mind', 'wisdom', 'action', 'wealth']) {
+      expect(codes, '五维概览应包含维度 ' + c).toContain(c);
+    }
+  });
+
+  // ===== 场景6:完整能量收支流程 =====
+
+  test('[场景6] 完整能量收支流程 - 期望全流程成功', async ({ request }) => {
+    test.fixme(true, 'CFC-EN-001:/api/energy/grant 返回 500 服务器内部错误,修复后移除本标记');
+    const taskId = Number(uniqueSuffix());
+    const amount = 5;
+
+    // 1) 发放
+    const grant = await apiPost(
+      request,
+      '/api/energy/grant',
+      { memberId: selfMemberId, taskId, amount, dimensionCode: DIMENSION },
+      ctx.token
+    );
+    expect(grant.code).toBe(200);
+
+    // 2) 流水可查
+    const logs = await apiPost(request, '/api/energy/logs', { memberId: selfMemberId }, ctx.token);
+    expect(logs.code).toBe(200);
+    expect(logs.data.records.some((r) => String(r.sourceId) === String(taskId))).toBeTruthy();
+
+    // 3) 概览可查
+    const overview = await apiPost(request, '/api/energy/overview', { memberId: selfMemberId }, ctx.token);
+    expect(overview.code).toBe(200);
+  });
+});

+ 235 - 0
tests/e2e/api/family-member-api.spec.js

@@ -0,0 +1,235 @@
+/**
+ * 【API级】家庭成员管理全流程
+ * ================================================================
+ * 对应 DOM 版:e2e/family-member-management.spec.js(远程无 SPA,DOM 版不可跑)
+ * 接口契约来源:FamilyMembersController(/api/family/member/*)
+ *   add     : { generationLevel, nickname, gender?, birthday?, relativeMemberId?, peerType? }
+ *   list    : { familyId? | visibleOnly? | includeFamily? }  越权 → code=403
+ *   switch  : { memberId }        切换到自己 → 「不能切换到自己」
+ *   kick    : { memberId }        仅家庭创建者可踢
+ *   update  : { memberId, nickname?, generationLevel?, ... }
+ * 运行:npx playwright test -c playwright.config.api.js --project=desktop
+ */
+const { test, expect } = require('@playwright/test');
+const { registerRole, apiPost, pickMemberByUserId, uniqueSuffix } = require('../helpers/api');
+
+test.describe('【API级】家庭成员管理全流程', () => {
+  let ctx;
+
+  test.beforeAll(async ({ request }) => {
+    ctx = await registerRole(request, 'parent', 'QA-FAMILY');
+  });
+
+  /** 创建一个独立的手动成员(非微信登录用户),返回 { id, nickname } */
+  async function createManualChild(request, tag) {
+    const nickname = 'QA_' + tag + '_' + uniqueSuffix();
+    const r = await apiPost(
+      request,
+      '/api/family/member/add',
+      { generationLevel: 'child', nickname, gender: 'male', birthday: '2015-06-15' },
+      ctx.token
+    );
+    expect(r.code, '添加成员应成功').toBe(200);
+    expect(r.data && r.data.id, '应返回成员ID').toBeTruthy();
+    return { id: r.data.id, nickname: r.data.nickname || nickname };
+  }
+
+  // ===== 场景1:成员列表(关系/成员数据可获取)=====
+
+  test('[场景1] 获取成员列表 - 期望返回成员数据', async ({ request }) => {
+    const r = await apiPost(request, '/api/family/member/list', {}, ctx.token);
+    expect(r.code).toBe(200);
+    expect(Array.isArray(r.data)).toBeTruthy();
+    expect(r.data.length).toBeGreaterThan(0);
+  });
+
+  test('[场景1b] 越权访问他人家庭 - 期望 403 拒绝', async ({ request }) => {
+    const r = await apiPost(request, '/api/family/member/list', { familyId: 999999999 }, ctx.token);
+    expect(r.code).toBe(403);
+    expect(String(r.message)).toContain('无权访问');
+  });
+
+  // ===== 场景2:添加新家庭成员 =====
+
+  test('[场景2] 添加新家庭成员 - 期望添加成功并返回完整信息', async ({ request }) => {
+    const m = await createManualChild(request, 'ADD');
+    const r = await apiPost(request, '/api/family/member/list', {}, ctx.token);
+    expect(r.code).toBe(200);
+    const hit = r.data.find((x) => String(x.id) === String(m.id));
+    expect(hit, '新增成员应出现在成员列表中').toBeTruthy();
+    expect(hit.nickname).toContain('QA_ADD');
+  });
+
+  test('[场景2b] 添加成员时昵称为空 - 期望返回错误提示', async ({ request }) => {
+    const r = await apiPost(
+      request,
+      '/api/family/member/add',
+      { generationLevel: 'child', nickname: '' },
+      ctx.token
+    );
+    expect(r.code).not.toBe(200);
+    expect(String(r.message)).toContain('昵称');
+  });
+
+  test('[场景2c] 添加成员时辈分等级为空 - 期望返回错误提示', async ({ request }) => {
+    const r = await apiPost(
+      request,
+      '/api/family/member/add',
+      { nickname: 'QA_NO_GEN_' + uniqueSuffix() },
+      ctx.token
+    );
+    expect(r.code).not.toBe(200);
+    expect(String(r.message)).toContain('辈分等级');
+  });
+
+  test('[场景2d] 添加成员时辈分等级非法 - 期望返回错误提示', async ({ request }) => {
+    const r = await apiPost(
+      request,
+      '/api/family/member/add',
+      { generationLevel: 'not_a_level', nickname: 'QA_BAD_GEN_' + uniqueSuffix() },
+      ctx.token
+    );
+    expect(r.code).not.toBe(200);
+    expect(String(r.message)).toContain('辈分等级');
+  });
+
+  // ===== 场景3:查看家庭成员列表 =====
+
+  test('[场景3] 查看家庭成员列表 - 期望返回所有成员及关系信息', async ({ request }) => {
+    const m = await createManualChild(request, 'LIST');
+    const r = await apiPost(request, '/api/family/member/list', {}, ctx.token);
+    expect(r.code).toBe(200);
+    expect(r.data.length).toBeGreaterThan(1);
+    const hit = r.data.find((x) => String(x.id) === String(m.id));
+    expect(hit).toBeTruthy();
+    // 关系信息应随成员一并返回(FamilyMemberVO: relationship / roleLabel / relativeLabel / generation)
+    expect(
+      'relationship' in hit || 'roleLabel' in hit || 'relativeLabel' in hit || 'generation' in hit
+    ).toBeTruthy();
+    expect(hit.relationship, '应返回关系名称').toBeTruthy();
+  });
+
+  // ===== 场景4:更新成员信息 =====
+
+  /**
+   * 【已知缺陷 CFC-FM-001】远程环境下 /api/family/member/update 更新 nickname
+   * 返回 code=200 但昵称未变更(gender/birthday 可正常更新),属字段级不生效缺陷;
+   * 本地源码 FamilyMemberService.updateMember 已含 nickname 分支,
+   * 疑似远程部署构建落后于当前源码,需研发核对版本。
+   */
+  test('[场景4] 更新成员关系/昵称 - 期望更新成功并返回新信息', async ({ request }) => {
+    test.fixme(true, 'CFC-FM-001:member/update 更新 nickname 返回成功但未生效,修复后移除本标记');
+    const m = await createManualChild(request, 'UPD');
+    const newName = 'QA_UPDATED_' + uniqueSuffix();
+    const r = await apiPost(
+      request,
+      '/api/family/member/update',
+      { memberId: m.id, nickname: newName },
+      ctx.token
+    );
+    expect(r.code, '更新成员应成功').toBe(200);
+    expect(r.data).toBeTruthy();
+
+    const list = await apiPost(request, '/api/family/member/list', {}, ctx.token);
+    const hit = list.data.find((x) => String(x.id) === String(m.id));
+    expect(hit.nickname).toBe(newName);
+  });
+
+  test('[场景4c] 已知缺陷复现 - nickname 未生效但 gender/birthday 生效', async ({ request }) => {
+    // 特征化用例:锁定当前(缺陷)行为;研发修复后本用例会失败,提示移除 场景4 的 fixme
+    const m = await createManualChild(request, 'UPDC');
+    const newName = 'QA_NICK_' + uniqueSuffix();
+
+    const upd = await apiPost(
+      request,
+      '/api/family/member/update',
+      { memberId: m.id, nickname: newName, gender: 'female', birthday: '2017-05-05' },
+      ctx.token
+    );
+    expect(upd.code).toBe(200);
+    expect(upd.data.nickname, 'CFC-FM-001 当前表现为昵称未变更').toBe(m.nickname);
+    // 对照组:其它字段可正常更新
+    expect(upd.data.gender).toBe('female');
+    expect(String(upd.data.birthday)).toContain('2017-05-05');
+  });
+
+  // ===== 场景5:切换到成员视角 =====
+
+  test('[场景5] 切换到成员视角 - 期望返回切换凭证', async ({ request }) => {
+    const m = await createManualChild(request, 'SW');
+    const r = await apiPost(request, '/api/family/member/switch', { memberId: m.id }, ctx.token);
+    expect(r.code, '切换到手动成员应成功').toBe(200);
+    expect(r.data).toBeTruthy();
+    expect(String(r.data.memberId)).toBe(String(m.id));
+    expect(r.data).toHaveProperty('effectiveRole');
+  });
+
+  test('[场景5b] 切换到本人视角 - 期望返回错误提示', async ({ request }) => {
+    const list = await apiPost(request, '/api/family/member/list', {}, ctx.token);
+    const self = pickMemberByUserId(list.data, ctx.userId);
+    expect(self, '成员列表应包含登录用户自身').toBeTruthy();
+
+    const r = await apiPost(request, '/api/family/member/switch', { memberId: self.id }, ctx.token);
+    expect(r.code).not.toBe(200);
+    expect(String(r.message)).toContain('不能切换到自己');
+  });
+
+  // ===== 场景6:移除家庭成员 =====
+
+  test('[场景6] 移除家庭成员 - 期望成员从家庭中移除', async ({ request }) => {
+    const m = await createManualChild(request, 'KICK');
+    const before = await apiPost(request, '/api/family/member/list', {}, ctx.token);
+    const beforeCount = before.data.length;
+
+    const r = await apiPost(request, '/api/family/member/kick', { memberId: m.id }, ctx.token);
+    expect(r.code, '踢出成员应成功').toBe(200);
+
+    const after = await apiPost(request, '/api/family/member/list', {}, ctx.token);
+    expect(after.data.length).toBeLessThan(beforeCount);
+    expect(after.data.find((x) => String(x.id) === String(m.id))).toBeFalsy();
+  });
+
+  test('[场景6b] 非管理员尝试踢人 - 期望权限不足拒绝', async ({ request }) => {
+    const target = await createManualChild(request, 'PERM');
+    const other = await registerRole(request, 'parent', 'QA-OTHER');
+
+    const r = await apiPost(request, '/api/family/member/kick', { memberId: target.id }, other.token);
+    expect(r.code).not.toBe(200);
+  });
+
+  // ===== 场景7:完整家庭成员管理流程(端到端)=====
+
+  test('[场景7] 完整家庭成员管理流程 - 期望全流程成功', async ({ request }) => {
+    // 1) 添加成员
+    const nickname = 'QA_E2E_' + uniqueSuffix();
+    const add = await apiPost(
+      request,
+      '/api/family/member/add',
+      { generationLevel: 'child', nickname, gender: 'female', birthday: '2016-03-08' },
+      ctx.token
+    );
+    expect(add.code).toBe(200);
+    const memberId = add.data.id;
+
+    // 2) 列表可见
+    const list = await apiPost(request, '/api/family/member/list', {}, ctx.token);
+    expect(list.data.find((x) => String(x.id) === String(memberId))).toBeTruthy();
+
+    // 3) 更新成员
+    const updated = 'QA_E2E_UPD_' + uniqueSuffix();
+    const upd = await apiPost(request, '/api/family/member/update', { memberId, nickname: updated }, ctx.token);
+    expect(upd.code).toBe(200);
+
+    // 4) 切换视角
+    const sw = await apiPost(request, '/api/family/member/switch', { memberId }, ctx.token);
+    expect(sw.code).toBe(200);
+
+    // 5) 移除成员
+    const kick = await apiPost(request, '/api/family/member/kick', { memberId }, ctx.token);
+    expect(kick.code).toBe(200);
+
+    // 6) 终态校验:成员已不在列表
+    const finalList = await apiPost(request, '/api/family/member/list', {}, ctx.token);
+    expect(finalList.data.find((x) => String(x.id) === String(memberId))).toBeFalsy();
+  });
+});

+ 92 - 0
tests/e2e/helpers/api.js

@@ -0,0 +1,92 @@
+/**
+ * API 级 e2e 共享助手
+ * ================================================================
+ * 背景:远程环境 http://cfc.iwintrue.com 仅部署后端 API(Spring Boot),
+ *      未托管前端 SPA(访问任意路径均返回 Spring Boot 白标 404),
+ *      因此基于 DOM 的 e2e(page.goto + 选择器等待)在远程不可运行。
+ *      本助手为「API 级 e2e」提供统一入口:
+ *        - registerRole:测试专用直注接口 /api/auth/direct-register 自注册,
+ *          不依赖种子数据库、不依赖前端表单登录、不打印任何敏感信息;
+ *        - apiPost:统一 POST 封装(项目约定接口一律使用 POST)。
+ *
+ * 安全约定:本文件及调用方一律不打印 token / 手机号等敏感信息。
+ */
+const { expect } = require('@playwright/test');
+
+/** 生成合法手机号(严格 11 位,匹配后端 ^1[3-9]\d{9}$ 校验,否则返回「手机号格式不正确」) */
+function randPhone() {
+  return '13' + String(Math.floor(Math.random() * 900000000) + 100000000);
+}
+
+/**
+ * 自注册账号并返回会话上下文
+ * @param {import('@playwright/test').APIRequestContext} request
+ * @param {string} role 角色:parent / teacher / admin ...
+ * @returns {Promise<{token:string,userId:*,familyId:*,role:string,roles:*}>}
+ */
+async function registerRole(request, role, nickname) {
+  const res = await request.post('/api/auth/direct-register', {
+    data: { phone: randPhone(), role, nickname: nickname || 'QA-' + role },
+  });
+  const body = await res.json().catch(() => ({}));
+  expect(body.code, 'direct-register 应当成功(code=200)').toBe(200);
+  const d = body.data || {};
+  expect(typeof d.token, '响应应包含 token').toBe('string');
+  return {
+    token: d.token,
+    userId: d.userId,
+    familyId: d.familyId,
+    role: d.role,
+    roles: d.roles,
+  };
+}
+
+/**
+ * 统一 POST 调用,返回 { status, code, message, data }
+ * 若响应非 JSON(如未捕获异常导致的 Spring 500),回退以 HTTP 状态码作为 code,
+ * 避免断言拿到 undefined 造成误判。
+ *
+ * 超时说明:远程 /api/family/member/add 冷启动首调实测约 16s(后续约 1.5s),
+ * 超过 Playwright 默认 actionTimeout(15s) 会误报超时,故显式放宽到 60s。
+ */
+const API_TIMEOUT_MS = 60000;
+
+async function apiPost(request, path, body, token) {
+  const headers = {};
+  if (token) headers.Authorization = 'Bearer ' + token;
+  const res = await request.post(path, { data: body || {}, headers, timeout: API_TIMEOUT_MS });
+  const raw = await res.text();
+  let json = null;
+  try {
+    json = raw ? JSON.parse(raw) : null;
+  } catch (e) {
+    json = null;
+  }
+  if (!json || typeof json !== 'object') {
+    return {
+      status: res.status(),
+      code: res.status(),
+      message: 'NON_JSON_RESPONSE',
+      data: null,
+    };
+  }
+  return {
+    status: res.status(),
+    code: json.code,
+    message: json.message,
+    data: json.data,
+  };
+}
+
+/** 从成员列表中找出对应登录用户的成员记录(用于「切换到自己」等用例) */
+function pickMemberByUserId(members, userId) {
+  if (!Array.isArray(members)) return null;
+  return members.find((m) => String(m.userId) === String(userId)) || null;
+}
+
+/** 统一时间戳后缀,避免同名字段唯一约束冲突 */
+function uniqueSuffix() {
+  return String(Date.now()).slice(-8);
+}
+
+module.exports = { randPhone, registerRole, apiPost, pickMemberByUserId, uniqueSuffix };

+ 44 - 0
tests/playwright.config.api.js

@@ -0,0 +1,44 @@
+/**
+ * Playwright API 级测试配置(远程后端)
+ * ================================================================
+ * 用途:针对远程后端 API(http://cfc.iwintrue.com)运行「API 级 e2e」。
+ * 特点:
+ *   - 无 globalSetup(绝不重置远程数据库)
+ *   - 独立 outputDir(.api-artifacts):API 用例不产出截图/trace,
+ *     避免累积大量产物触发本地 safe-delete 批量守卫,导致运行前被中断
+ *   - 仅 list reporter,不生成 html 报告目录
+ *   - 用例通过 request fixture 直连 /api/*,不依赖前端 SPA
+ *
+ * 运行:npx playwright test <spec> -c playwright.config.api.js --project=desktop
+ */
+var path = require('path');
+
+module.exports = {
+  testDir: path.join(__dirname, 'e2e'),
+  testMatch: /api[\\/].*\.spec\.js$/,
+  // 远程接口冷启动较慢(/api/family/member/add 首调实测约 16s),单用例含多次调用,放宽用例级超时
+  timeout: 180000,
+  retries: 0,
+  workers: 1,
+  outputDir: path.join(__dirname, '.api-artifacts'),
+  reporter: [['list']],
+  use: {
+    baseURL: 'http://cfc.iwintrue.com',
+    headless: true,
+    ignoreHTTPSErrors: true,
+    actionTimeout: 15000,
+    navigationTimeout: 60000,
+    screenshot: 'off',
+    trace: 'off',
+    video: 'off',
+  },
+  projects: [
+    {
+      name: 'desktop',
+      use: {
+        browserName: 'chromium',
+        viewport: { width: 1440, height: 900 },
+      },
+    },
+  ],
+};

+ 26 - 0
tests/playwright.config.smoke.js

@@ -0,0 +1,26 @@
+/**
+ * 最小浏览器冒烟配置(无 globalSetup,避免重置远程测试库)
+ * 仅用于验证本机 Chromium 能否真实拉起并打开页面。
+ */
+var path = require('path');
+
+module.exports = {
+  testDir: path.join(__dirname, 'e2e'),
+  timeout: 60000,
+  retries: 0,
+  workers: 1,
+  use: {
+    baseURL: process.env.PW_BASE_URL || 'http://127.0.0.1:9082',
+    headless: true,
+    ignoreHTTPSErrors: true,
+    actionTimeout: 10000,
+    navigationTimeout: 30000,
+  },
+  projects: [
+    {
+      name: 'chromium',
+      use: { browserName: 'chromium', viewport: { width: 1280, height: 800 } },
+    },
+  ],
+  reporter: [['list']],
+};