diff --git a/docs/README.md b/docs/README.md index 5555664..6376784 100644 --- a/docs/README.md +++ b/docs/README.md @@ -28,7 +28,7 @@ docs/ | 位置 | 内容 | 维护约定 | |------|------|---------| | `docs/requirements/*.md` | **待办/实施中需求**的问题、目标、方案权衡、checklist、验收与进度 | 接到需求先记录;完成后同步 design/业务/changelog 并更新状态 | -| `docs/业务/*.md` | **当前已实现**行为的分模块文档(错题本与复习、用户反馈、自由录入、历史与分享、主题设置、结果页与发音评测、账户资料、渐进式场景练习) | 每次行为变更必须同步更新 | +| `docs/业务/*.md` | **当前已实现**行为的分模块文档(错题本与复习、用户反馈、自由录入、历史与分享、主题设置、结果页与发音评测、账户资料、渐进式场景练习、练习偏好) | 每次行为变更必须同步更新 | | `docs/design/schema.md` | MongoDB 集合字段(数据模型事实源) | 字段变更必须同步 | | `docs/design/*.md` | 已确定的当前数据结构、架构与交互设计(schema/ids/storage/spec) | 设计变化时同步,不记录实施进度 | | `docs/design/场景练习/*.md` | 场景模式总览 / 公共题分类 / 混合练习 / 题目评测 | 场景链路变化时同步 | @@ -81,6 +81,7 @@ graph TB | `/api/auth/profile` | PATCH | 鉴权后修改当前用户昵称 | MongoDB | | `/api/auth/profile/avatar` | POST/DELETE | 鉴权后上传头像或恢复默认头像 | MongoDB + OSS | | `/api/auth/avatar/{userId}` | GET | 版本化头像地址跳转到私有 OSS 短效签名 URL | MongoDB + OSS 签名 | +| `/api/auth/practice-preferences` | GET/PUT | 练习偏好(难度/目的)按用户读写;未设置 GET 404,非法值 422 | MongoDB | | `/api/scenarios/next` | GET | 派题:定制题 > 未练公共题 > 轮换 | MongoDB + OSS 签名 | | `/api/scenarios/practice-word` | POST | 「用这个词练一题」即时出定制场景题 | 文本模型 + 文生图 | | `/api/practice-sessions` | GET/POST | 创建会话(存场景快照)/ 历史列表;`/{pid}` 读单条 | MongoDB | diff --git "a/docs/changelog/20260827-093359-fix-\347\273\203\344\271\240\345\201\217\345\245\275\346\234\215\345\212\241\347\253\257\345\214\226-\347\224\250\346\210\267\350\256\276\347\275\256\350\267\237\350\264\246\345\217\267\350\265\260.md" "b/docs/changelog/20260827-093359-fix-\347\273\203\344\271\240\345\201\217\345\245\275\346\234\215\345\212\241\347\253\257\345\214\226-\347\224\250\346\210\267\350\256\276\347\275\256\350\267\237\350\264\246\345\217\267\350\265\260.md" new file mode 100644 index 0000000..459d8db --- /dev/null +++ "b/docs/changelog/20260827-093359-fix-\347\273\203\344\271\240\345\201\217\345\245\275\346\234\215\345\212\241\347\253\257\345\214\226-\347\224\250\346\210\267\350\256\276\347\275\256\350\267\237\350\264\246\345\217\267\350\265\260.md" @@ -0,0 +1,21 @@ +# fix: 练习偏好服务端化——用户设置跟账号走,不再存浏览器 + +问题:练习偏好(难度/目的)只存在浏览器 localStorage,换设备/清缓存后要求重新设置(用户 13800000001 实测反馈)。偏好应是用户设置,不是浏览器设置。 + +## 后端(事实源) +- `users.practicePreferences = {level, purpose}`;`GET /api/auth/practice-preferences`(未设置 404)/ `PUT`(非法取值 422、跨用户 403)。 +- 登录响应携带已设置的偏好(未设置为 null)。 + +## 前端(缓存 + 对账) +- 练习页挂载时与服务端对账:服务端有值以服务端为准;本地有值而服务端没有(旧版浏览器设置)自动迁移到服务端,设置不丢;两边都没有才出首次选择题。 +- 偏好设置页进入时先拉服务端值;修改立即写服务端。 +- 登录成功即把响应里的偏好落本地缓存,减少一次往返。 +- localStorage 降级为缓存;对账完成前不抽题、不出选择题。 + +## 验证 +- server:390 passed(新增 7 条偏好集成测试:往返/覆盖/登录携带/未设置/非法值/跨用户);ruff + 代码质量门禁通过。 +- web:337 passed(新增 3 条对账用例 + 2 条 client 用例),覆盖率 88.82/82.11/87.37/92.61(门禁 80/70/80/80),lint 0 errors。 +- E2E 全矩阵 60 passed(5 引擎);journey 登录流程与渐进提示流程的 mock 补了偏好接口分支(GET 404 / PUT 回显)。 + +## 文档 +- schema.md users 集合新增字段说明;新增 `业务/9-练习偏好与难度选择.md`;docs/README API 表补行。 diff --git a/docs/changelog/README.md b/docs/changelog/README.md index 14671ac..dda884a 100644 --- a/docs/changelog/README.md +++ b/docs/changelog/README.md @@ -6,6 +6,7 @@ | 时间 | 文件 | 摘要 | |---|---|---| +| 2026-08-27 09:33:59 | [20260827-093359-fix-练习偏好服务端化-用户设置跟账号走.md](20260827-093359-fix-练习偏好服务端化-用户设置跟账号走.md) | 练习偏好(难度/目的)改为服务端用户设置,登录响应携带,旧浏览器设置自动迁移。 | | 2026-08-27 09:06:46 | [20260827-090646-fix-登录后回跳原页面backurl.md](20260827-090646-fix-登录后回跳原页面backurl.md) | 未登录访问受保护页面时,登录成功后回到原入口(含指定题目查询参数),外链拒绝。 | | 2026-08-27 08:30:34 | [20260827-083034-feat-渐进式场景提示-实现与试点就绪.md](20260827-083034-feat-渐进式场景提示-实现与试点就绪.md) | 渐进式场景提示代码与试点内容就绪:slug 精确入口、按需提示、幂等创建、评分隔离、10 题审核稿;生产激活待分批投放。 | | 2026-08-26 15:27:01 | [20260826-152701-chore-渐进式场景提示实施规格.md](20260826-152701-chore-渐进式场景提示实施规格.md) | 新增 agent-ready 渐进提示规格,分开代码交付、生产激活与产品试点验收。 | diff --git a/docs/design/schema.md b/docs/design/schema.md index c285de8..38494f1 100644 --- a/docs/design/schema.md +++ b/docs/design/schema.md @@ -18,6 +18,7 @@ }, "avatarVersion": 1787555910000, // 每次上传更新,用于版本化地址刷新缓存 "sourceType": "human | ai_test", // 数据来源;普通用户默认 human,自动体验专用账号为 ai_test + "practicePreferences": { "level": "daily", "purpose": "travel" }, // 练习偏好(难度/目的):服务端事实源,跨设备一致;未设置时缺省 "createdAt": datetime, "updatedAt": datetime // 修改资料后写入;历史用户可缺省 } @@ -26,6 +27,7 @@ `sourceType` 在用户首次创建时确定,后续普通登录不改写。历史缺字段用户按 `human` 处理。 生产分析排除自动体验数据时使用 `{sourceType: {$ne: "ai_test"}}`,以兼容历史记录。 昵称通过鉴权接口更新,服务端去除首尾空白并合并连续空白;空昵称、超过 24 个字符或含控制字符时拒绝保存。 +练习偏好通过 `GET/PUT /api/auth/practice-preferences` 读写(非法取值 422、跨用户 403、未设置读取 404);登录响应携带已设置的偏好。前端 localStorage 只是缓存:服务端有值以服务端为准,本地有值而服务端没有时自动迁移。 头像源文件只允许 JPG、PNG、WebP,最大 25 MB。前端先方形裁剪,服务端再次解码、去 EXIF,生成 1024 主图(小图不放大)和 256 缩略图;默认头像时 `avatar` / `avatarVersion` 均缺省。手机号当前无更新接口。 ## authSessions(登录会话) diff --git "a/docs/\344\270\232\345\212\241/9-\347\273\203\344\271\240\345\201\217\345\245\275\344\270\216\351\232\276\345\272\246\351\200\211\346\213\251.md" "b/docs/\344\270\232\345\212\241/9-\347\273\203\344\271\240\345\201\217\345\245\275\344\270\216\351\232\276\345\272\246\351\200\211\346\213\251.md" new file mode 100644 index 0000000..fdf9d26 --- /dev/null +++ "b/docs/\344\270\232\345\212\241/9-\347\273\203\344\271\240\345\201\217\345\245\275\344\270\216\351\232\276\345\272\246\351\200\211\346\213\251.md" @@ -0,0 +1,17 @@ +# 9. 练习偏好(难度与目的) + +> 数据契约事实源:`docs/design/schema.md` users 集合。 + +## 是什么 +练习偏好(难度 level × 目的 purpose)决定抽题方向。它是**用户设置**,存在服务端、跟账号走,换设备、换浏览器、清缓存都不丢。 + +## 当前行为 +- **首次练习**:没设置过偏好的用户进入练习页会先看到选择题(目的 + 难度),选完开始练习并保存。 +- **保存与读取**:`PUT /api/auth/practice-preferences` 写入 `users.practicePreferences`;`GET` 读取;非法取值 422,跨用户 403,未设置 GET 返回 404。 +- **登录即带**:登录响应携带已设置的偏好,前端直接落缓存,减少一次往返。 +- **对账与迁移**:练习页挂载时以服务端为准对账;本地浏览器有旧设置而服务端没有时自动迁移到服务端(老用户无感升级,设置不丢)。 +- **修改入口**:我的页 → 设置区 → 练习偏好;修改立即生效于下一次抽题。 +- **影响面**:偏好只影响随机抽题方向;指定题目入口(`?scenario=`)、自由说、已有会话均不依赖偏好。 + +## 历史说明 +2026-08-27 之前偏好只存浏览器 localStorage,换设备会要求重新设置;现已服务端化,本地存储降级为缓存。 diff --git a/server/routes/auth.py b/server/routes/auth.py index 2bbeeb5..d78e081 100644 --- a/server/routes/auth.py +++ b/server/routes/auth.py @@ -4,16 +4,17 @@ from datetime import datetime, timezone from typing import Literal -from fastapi import APIRouter, Depends, File, HTTPException, UploadFile +from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile from fastapi.responses import RedirectResponse from pydantic import BaseModel from db.connection import get_db from services import oss_storage from services.avatar_images import InvalidAvatarImage, build_avatar_variants -from services.auth_tokens import create_session, current_user_id +from services.auth_tokens import assert_same_user, create_session, current_user_id from services.storage_paths import avatar_key from utils.data_source import normalize_source_type +from utils.mongo_ids import id_filter from utils.id_generator import avatar_id, user_id from utils.mongo_ids import id_filter @@ -111,16 +112,72 @@ async def login(req: LoginRequest): uid = str(user["_id"]) token = await create_session(uid) + stored_prefs = user.get("practicePreferences") return { "userId": uid, "phone": req.phone, "nickname": user["nickname"], "avatarUrl": _avatar_url(user), "sourceType": source_type, + "practicePreferences": stored_prefs if _valid_practice_preferences(stored_prefs) else None, "token": token, } +# 练习偏好(难度/目的):按用户存服务端,跨设备一致;前端 localStorage 只是缓存 +PRACTICE_LEVELS = {"beginner", "daily", "advanced", "challenge"} +PRACTICE_PURPOSES = {"travel", "work", "ielts", "toefl", "dailyLife"} + + +class PracticePreferencesRequest(BaseModel): + userId: str + level: str + purpose: str + + +def _valid_practice_preferences(prefs: object) -> bool: + return ( + isinstance(prefs, dict) + and prefs.get("level") in PRACTICE_LEVELS + and prefs.get("purpose") in PRACTICE_PURPOSES + ) + + +@router.get("/practice-preferences") +async def get_practice_preferences( + userId: str = Query(...), + token_user_id: str = Depends(current_user_id), +): + """读当前用户练习偏好;未设置返回 404,前端据此出首次选择。""" + assert_same_user(userId, token_user_id) + user = await get_db().users.find_one( + id_filter(token_user_id), {"practicePreferences": 1} + ) + prefs = (user or {}).get("practicePreferences") + if not _valid_practice_preferences(prefs): + raise HTTPException(404, "尚未设置练习偏好") + return {"level": prefs["level"], "purpose": prefs["purpose"]} + + +@router.put("/practice-preferences") +async def save_practice_preferences( + req: PracticePreferencesRequest, + token_user_id: str = Depends(current_user_id), +): + """保存练习偏好(服务端事实源)。非法取值 422。""" + assert_same_user(req.userId, token_user_id) + if req.level not in PRACTICE_LEVELS or req.purpose not in PRACTICE_PURPOSES: + raise HTTPException(422, "非法的练习偏好") + prefs = {"level": req.level, "purpose": req.purpose} + result = await get_db().users.update_one( + id_filter(token_user_id), + {"$set": {"practicePreferences": prefs}}, + ) + if result.matched_count == 0: + raise HTTPException(404, "用户不存在") + return prefs + + @router.patch("/profile") async def update_profile( req: UpdateProfileRequest, diff --git a/server/tests/integration/test_auth.py b/server/tests/integration/test_auth.py index 5e954e0..93c06ae 100644 --- a/server/tests/integration/test_auth.py +++ b/server/tests/integration/test_auth.py @@ -186,3 +186,94 @@ async def fake_delete(key): assert deleted[1].endswith("/thumbnail.jpg") assert missing.status_code == 404 assert relogin.json()["avatarUrl"] is None + + +# ── 练习偏好:服务端事实源(跨设备一致) ───────────────── + +def test_practice_preferences_unset_returns_404(client): + from tests.conftest import login_headers + uid, headers = login_headers(client, "13800007001") + resp = client.get(f"/api/auth/practice-preferences?userId={uid}", headers=headers) + assert resp.status_code == 404 + + +def test_practice_preferences_put_then_get_roundtrip(client): + from tests.conftest import login_headers + uid, headers = login_headers(client, "13800007002") + put = client.put( + "/api/auth/practice-preferences", + json={"userId": uid, "level": "advanced", "purpose": "work"}, + headers=headers, + ) + assert put.status_code == 200 + assert put.json() == {"level": "advanced", "purpose": "work"} + got = client.get(f"/api/auth/practice-preferences?userId={uid}", headers=headers) + assert got.status_code == 200 + assert got.json() == {"level": "advanced", "purpose": "work"} + + +def test_practice_preferences_overwrite(client): + from tests.conftest import login_headers + uid, headers = login_headers(client, "13800007008") + client.put( + "/api/auth/practice-preferences", + json={"userId": uid, "level": "daily", "purpose": "travel"}, + headers=headers, + ) + client.put( + "/api/auth/practice-preferences", + json={"userId": uid, "level": "challenge", "purpose": "ielts"}, + headers=headers, + ) + got = client.get(f"/api/auth/practice-preferences?userId={uid}", headers=headers) + assert got.json() == {"level": "challenge", "purpose": "ielts"} + + +def test_login_response_carries_saved_preferences(client): + from tests.conftest import login_headers + uid, headers = login_headers(client, "13800007003") + client.put( + "/api/auth/practice-preferences", + json={"userId": uid, "level": "challenge", "purpose": "ielts"}, + headers=headers, + ) + again = client.post("/api/auth/login", json={"phone": "13800007003"}) + assert again.json()["practicePreferences"] == {"level": "challenge", "purpose": "ielts"} + + +def test_login_without_preferences_returns_null(client): + resp = client.post("/api/auth/login", json={"phone": "13800007004"}) + assert resp.json()["practicePreferences"] is None + + +def test_practice_preferences_rejects_invalid_values(client): + from tests.conftest import login_headers + uid, headers = login_headers(client, "13800007005") + bad_level = client.put( + "/api/auth/practice-preferences", + json={"userId": uid, "level": "hard", "purpose": "work"}, + headers=headers, + ) + assert bad_level.status_code == 422 + bad_purpose = client.put( + "/api/auth/practice-preferences", + json={"userId": uid, "level": "daily", "purpose": "unknown"}, + headers=headers, + ) + assert bad_purpose.status_code == 422 + # 非法写入不落库 + assert client.get(f"/api/auth/practice-preferences?userId={uid}", headers=headers).status_code == 404 + + +def test_practice_preferences_cross_user_forbidden(client): + from tests.conftest import login_headers + _, headers_a = login_headers(client, "13800007006") + uid_b, _ = login_headers(client, "13800007007") + got = client.get(f"/api/auth/practice-preferences?userId={uid_b}", headers=headers_a) + assert got.status_code == 403 + put = client.put( + "/api/auth/practice-preferences", + json={"userId": uid_b, "level": "daily", "purpose": "travel"}, + headers=headers_a, + ) + assert put.status_code == 403 diff --git a/web/e2e/journey.spec.js b/web/e2e/journey.spec.js index 36484af..952510c 100644 --- a/web/e2e/journey.spec.js +++ b/web/e2e/journey.spec.js @@ -73,6 +73,11 @@ async function setup(page, { loggedIn = true } = {}) { if (method === "POST" && pathname === "/api/auth/login") { return route.fulfill({ json: USER }); } + if (pathname === "/api/auth/practice-preferences") { + // 服务端事实源:默认未设置(404);PUT 回显。未登录/迁移/首测三条路径都走这里 + if (method === "PUT") return route.fulfill({ json: route.request().postDataJSON() }); + return route.fulfill({ status: 404, json: { detail: "尚未设置" } }); + } if (pathname === "/api/scenarios/next") { return route.fulfill({ json: SCENARIO_NEXT }); } diff --git a/web/e2e/progressive.spec.js b/web/e2e/progressive.spec.js index de48f24..581d4e2 100644 --- a/web/e2e/progressive.spec.js +++ b/web/e2e/progressive.spec.js @@ -71,6 +71,11 @@ async function setup(page, { slugOk = true, revealed = 0, loggedIn = true } = {} await page.route("**/api/**", async (route) => { const { pathname } = new URL(route.request().url()); const method = route.request().method(); + if (pathname === "/api/auth/practice-preferences") { + // 服务端事实源:默认未设置(404);PUT 回显。未登录/迁移/首测三条路径都走这里 + if (method === "PUT") return route.fulfill({ json: route.request().postDataJSON() }); + return route.fulfill({ status: 404, json: { detail: "尚未设置" } }); + } if (pathname === "/api/auth/login" && method === "POST") { return route.fulfill({ json: USER }); } diff --git a/web/src/api/client.js b/web/src/api/client.js index fda56be..f89d375 100644 --- a/web/src/api/client.js +++ b/web/src/api/client.js @@ -190,6 +190,12 @@ export const api = { }, removeAvatar: () => request("/auth/profile/avatar", { method: "DELETE" }), + // 练习偏好:服务端是事实源(跨设备一致),本地 localStorage 只是缓存 + getPracticePreferences: (userId) => + request(`/auth/practice-preferences?userId=${encodeURIComponent(userId)}`), + savePracticePreferences: (data) => + request("/auth/practice-preferences", { method: "PUT", body: data }), + // 指定题目入口:按 slug 精确取题(/practice?scenario= 用),失败由调用方展示不可用态 scenarioBySlug: (slug) => request(`/scenarios/by-slug/${encodeURIComponent(slug)}`), diff --git a/web/src/api/client.test.js b/web/src/api/client.test.js index c7ab9c4..489971d 100644 --- a/web/src/api/client.test.js +++ b/web/src/api/client.test.js @@ -449,3 +449,35 @@ describe("渐进式提示与指定题目接口", () => { expect(JSON.parse(opts.body)).toEqual({ requestId: "req-uuid" }); }); }); + +describe("练习偏好接口", () => { + let fetchMock; + + beforeEach(() => { + localStorage.clear(); + storeToken(); + fetchMock = vi.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve({}) }); + vi.stubGlobal("fetch", fetchMock); + }); + + afterEach(() => { + localStorage.clear(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it("getPracticePreferences 带 userId 查询", async () => { + await api.getPracticePreferences("u_test"); + const [url, opts] = fetchMock.mock.calls[0]; + expect(url).toBe("/api/auth/practice-preferences?userId=u_test"); + expect(opts.headers.Authorization).toBe("Bearer tok_test"); + }); + + it("savePracticePreferences 用 PUT 带完整偏好", async () => { + await api.savePracticePreferences({ userId: "u_test", level: "daily", purpose: "travel" }); + const [url, opts] = fetchMock.mock.calls[0]; + expect(url).toBe("/api/auth/practice-preferences"); + expect(opts.method).toBe("PUT"); + expect(JSON.parse(opts.body)).toEqual({ userId: "u_test", level: "daily", purpose: "travel" }); + }); +}); diff --git a/web/src/context/UserContext.jsx b/web/src/context/UserContext.jsx index 1c7b1dd..83d8447 100644 --- a/web/src/context/UserContext.jsx +++ b/web/src/context/UserContext.jsx @@ -1,6 +1,7 @@ import { useState, useEffect } from "react"; import { api } from "../api/client.js"; import { UserContext } from "./user-context.js"; +import { savePracticePreferences } from "../lib/practicePreferences.js"; export function UserProvider({ children }) { const [user, setUser] = useState(() => { @@ -24,6 +25,10 @@ export function UserProvider({ children }) { const login = async (phone) => { const data = await api.login(phone); + // 登录响应带回服务端偏好:先落本地缓存,练习页再对账 + if (data.practicePreferences) { + savePracticePreferences(data.userId, data.practicePreferences); + } setUser(data); return data; }; diff --git a/web/src/pages/PracticePage.feedback.test.jsx b/web/src/pages/PracticePage.feedback.test.jsx index e405221..860ae5a 100644 --- a/web/src/pages/PracticePage.feedback.test.jsx +++ b/web/src/pages/PracticePage.feedback.test.jsx @@ -15,6 +15,9 @@ import { vi.mock("../api/client.js", () => ({ api: { + // 偏好对账默认走"服务端未设置/离线":使用本地缓存路径(与生产降级一致) + getPracticePreferences: vi.fn().mockRejectedValue(new Error("offline")), + savePracticePreferences: vi.fn().mockResolvedValue({}), nextScenario: vi.fn(), nextFreeTopic: vi.fn(), createPractice: vi.fn(), diff --git a/web/src/pages/PracticePage.free.test.jsx b/web/src/pages/PracticePage.free.test.jsx index a63f255..dd2e0e0 100644 --- a/web/src/pages/PracticePage.free.test.jsx +++ b/web/src/pages/PracticePage.free.test.jsx @@ -14,6 +14,9 @@ import { vi.mock("../api/client.js", () => ({ api: { + // 偏好对账默认走"服务端未设置/离线":使用本地缓存路径(与生产降级一致) + getPracticePreferences: vi.fn().mockRejectedValue(new Error("offline")), + savePracticePreferences: vi.fn().mockResolvedValue({}), nextScenario: vi.fn(), nextFreeTopic: vi.fn(), createPractice: vi.fn(), diff --git a/web/src/pages/PracticePage.jsx b/web/src/pages/PracticePage.jsx index 80dfe70..4f6fe35 100644 --- a/web/src/pages/PracticePage.jsx +++ b/web/src/pages/PracticePage.jsx @@ -45,10 +45,9 @@ export default function PracticePage() { const progressive = useProgressiveScenario(t); const { pendingScenario, hintCount, hintBusy, hintError } = progressive; const [practicePrefs, setPracticePrefs] = useState(() => getPracticePreferences(user.userId)); - const [needsPrefs, setNeedsPrefs] = useState( - () => !practiceId && mode !== "free" && !searchParams.get("scenario") - && !hasPracticePreferences(user.userId), - ); + // 偏好对账:服务端是事实源;对账完成前不出选择题也不抽题 + const [prefsResolved, setPrefsResolved] = useState(false); + const [needsPrefs, setNeedsPrefs] = useState(false); const [transcript, setTranscript] = useState(""); const [transcriptionError, setTranscriptionError] = useState(false); const [result, setResult] = useState(null); @@ -170,6 +169,31 @@ export default function PracticePage() { navigate("/practice"); }; + // 偏好对账:服务端事实源。本地有值服务端没有(旧版浏览器设置)自动迁移,设置不丢。 + useEffect(() => { + let cancelled = false; + api.getPracticePreferences(user.userId).then((serverPrefs) => { + if (cancelled) return; + const synced = savePracticePreferences(user.userId, serverPrefs); + setPracticePrefs(synced); + setNeedsPrefs(false); + setPrefsResolved(true); + }).catch(() => { + if (cancelled) return; + if (hasPracticePreferences(user.userId)) { + api.savePracticePreferences({ + userId: user.userId, + ...getPracticePreferences(user.userId), + }).catch(console.error); + } else { + setNeedsPrefs(!practiceId && mode !== "free" && !searchParams.get("scenario")); + } + setPrefsResolved(true); + }); + return () => { cancelled = true; }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [user.userId]); + useEffect(() => { // 已经在内存里加载好这道题(刚 startNewRound 后 navigate 改 URL 触发的回流)就别再拉 if (practiceId && session?._id === practiceId) return; @@ -212,7 +236,7 @@ export default function PracticePage() { if (!hasTopic()) Promise.resolve().then(startNewFreeRound); return; } - if (!hasPracticePreferences(user.userId)) { + if (!prefsResolved || !hasPracticePreferences(user.userId)) { return; } const prefs = getPracticePreferences(user.userId); @@ -222,7 +246,7 @@ export default function PracticePage() { }); return () => { cancelled = true; }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [practiceId]); + }, [practiceId, prefsResolved]); // 组件卸载时取消 SSE(录音机由 usePracticeRecorder 自行清理) useEffect(() => () => { @@ -242,6 +266,7 @@ export default function PracticePage() { const saved = savePracticePreferences(user.userId, practicePrefs); setPracticePrefs(saved); setNeedsPrefs(false); + api.savePracticePreferences({ userId: user.userId, ...saved }).catch(console.error); startNewRound(null, saved); }; diff --git a/web/src/pages/PracticePage.prefs.test.jsx b/web/src/pages/PracticePage.prefs.test.jsx new file mode 100644 index 0000000..a2b6108 --- /dev/null +++ b/web/src/pages/PracticePage.prefs.test.jsx @@ -0,0 +1,92 @@ +import { screen, waitFor } from "@testing-library/react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { + USER, + SCENARIO_B, + PREFS, + setup, + installMediaStubs, +} from "./PracticePage.feedback.helpers.jsx"; +import { getPracticePreferences } from "../lib/practicePreferences.js"; + +vi.mock("../api/client.js", () => ({ + api: { + nextScenario: vi.fn(), + nextFreeTopic: vi.fn(), + createPractice: vi.fn(), + getPractice: vi.fn(), + transcribeAudio: vi.fn(), + uploadRecording: vi.fn(), + addReviewItems: vi.fn(), + deleteReviewItem: vi.fn(), + tts: vi.fn(), + getPracticePreferences: vi.fn().mockRejectedValue(new Error("offline")), + savePracticePreferences: vi.fn().mockResolvedValue({}), + }, + correctStream: vi.fn(), + chatStream: vi.fn(), +})); + +vi.mock("../utils/tts.js", () => ({ + speak: vi.fn().mockResolvedValue(null), + stop: vi.fn(), + isCached: vi.fn().mockReturnValue(false), +})); + +describe("PracticePage 练习偏好服务端化", () => { + beforeEach(async () => { + localStorage.clear(); + sessionStorage.clear(); + vi.clearAllMocks(); + const { api } = await import("../api/client.js"); + api.nextScenario.mockResolvedValue(SCENARIO_B); + api.nextFreeTopic.mockResolvedValue({ _id: "ft_1", text: "A topic", zh: "话题" }); + api.createPractice.mockResolvedValue({ _id: "sess_xyz" }); + api.getPractice.mockResolvedValue({}); + // 恢复工厂默认(clearAllMocks 会清掉实现) + api.getPracticePreferences.mockRejectedValue(new Error("offline")); + api.savePracticePreferences.mockResolvedValue({}); + installMediaStubs(); + }); + + it("服务端有偏好时直接使用:不出选择题,本地缓存同步为服务端值", async () => { + const { api } = await import("../api/client.js"); + api.getPracticePreferences.mockResolvedValue({ level: "advanced", purpose: "work" }); + + setup("/practice", { prefs: false }); + + await waitFor(() => + expect(api.nextScenario).toHaveBeenCalledWith( + USER.userId, + [], + { level: "advanced", purpose: "work" }, + ), + ); + expect(screen.queryByText("What do you want to practice?")).not.toBeInTheDocument(); + expect(getPracticePreferences(USER.userId)).toEqual({ level: "advanced", purpose: "work" }); + }); + + it("服务端没有而本地有(旧版浏览器设置):自动迁移到服务端", async () => { + const { api } = await import("../api/client.js"); + setup("/practice"); + + await waitFor(() => + expect(api.savePracticePreferences).toHaveBeenCalledWith({ + userId: USER.userId, + level: PREFS.level, + purpose: PREFS.purpose, + }), + ); + await waitFor(() => expect(api.nextScenario).toHaveBeenCalledWith(USER.userId, [], PREFS)); + }); + + it("服务端和本地都没有:显示首次选择题,不抽题", async () => { + const { api } = await import("../api/client.js"); + setup("/practice", { prefs: false }); + + await waitFor(() => + expect(screen.getByText("What do you want to practice?")).toBeInTheDocument(), + ); + expect(api.nextScenario).not.toHaveBeenCalled(); + }); +}); diff --git a/web/src/pages/PracticePage.progressive.test.jsx b/web/src/pages/PracticePage.progressive.test.jsx index 047e397..f872aa2 100644 --- a/web/src/pages/PracticePage.progressive.test.jsx +++ b/web/src/pages/PracticePage.progressive.test.jsx @@ -5,6 +5,9 @@ import { setup, installMediaStubs, USER } from "./PracticePage.feedback.helpers. vi.mock("../api/client.js", () => ({ api: { + // 偏好对账默认走"服务端未设置/离线":使用本地缓存路径(与生产降级一致) + getPracticePreferences: vi.fn().mockRejectedValue(new Error("offline")), + savePracticePreferences: vi.fn().mockResolvedValue({}), nextScenario: vi.fn(), scenarioBySlug: vi.fn(), createPractice: vi.fn(), diff --git a/web/src/pages/PracticePage.result-actions.test.jsx b/web/src/pages/PracticePage.result-actions.test.jsx index 7572398..8c99bb3 100644 --- a/web/src/pages/PracticePage.result-actions.test.jsx +++ b/web/src/pages/PracticePage.result-actions.test.jsx @@ -11,6 +11,9 @@ import { vi.mock("../api/client.js", () => ({ api: { + // 偏好对账默认走"服务端未设置/离线":使用本地缓存路径(与生产降级一致) + getPracticePreferences: vi.fn().mockRejectedValue(new Error("offline")), + savePracticePreferences: vi.fn().mockResolvedValue({}), nextScenario: vi.fn(), nextFreeTopic: vi.fn(), createPractice: vi.fn(), diff --git a/web/src/pages/PracticePage.result-stability.test.jsx b/web/src/pages/PracticePage.result-stability.test.jsx index 78d06d5..2097da5 100644 --- a/web/src/pages/PracticePage.result-stability.test.jsx +++ b/web/src/pages/PracticePage.result-stability.test.jsx @@ -12,6 +12,9 @@ import { vi.mock("../api/client.js", () => ({ api: { + // 偏好对账默认走"服务端未设置/离线":使用本地缓存路径(与生产降级一致) + getPracticePreferences: vi.fn().mockRejectedValue(new Error("offline")), + savePracticePreferences: vi.fn().mockResolvedValue({}), nextScenario: vi.fn(), nextFreeTopic: vi.fn(), createPractice: vi.fn(), diff --git a/web/src/pages/PracticePage.test.jsx b/web/src/pages/PracticePage.test.jsx index 31874b2..a85b6bb 100644 --- a/web/src/pages/PracticePage.test.jsx +++ b/web/src/pages/PracticePage.test.jsx @@ -15,6 +15,9 @@ import { vi.mock("../api/client.js", () => ({ api: { + // 偏好对账默认走"服务端未设置/离线":使用本地缓存路径(与生产降级一致) + getPracticePreferences: vi.fn().mockRejectedValue(new Error("offline")), + savePracticePreferences: vi.fn().mockResolvedValue({}), nextScenario: vi.fn(), nextFreeTopic: vi.fn(), createPractice: vi.fn(), @@ -211,7 +214,10 @@ describe("PracticePage", () => { const { api } = await import("../api/client.js"); setup("/practice", { prefs: false }); - expect(screen.getByText("What do you want to practice?")).toBeInTheDocument(); + // 偏好对账(服务端查询)完成后才判定首次选择题,等待异步落定 + await waitFor(() => + expect(screen.getByText("What do you want to practice?")).toBeInTheDocument(), + ); expect(api.nextScenario).not.toHaveBeenCalled(); await userEvent.click(screen.getByText("IELTS")); diff --git a/web/src/pages/PracticePreferencePage.jsx b/web/src/pages/PracticePreferencePage.jsx index ac7d0a7..0ed2756 100644 --- a/web/src/pages/PracticePreferencePage.jsx +++ b/web/src/pages/PracticePreferencePage.jsx @@ -8,6 +8,7 @@ import { getPracticePreferences, savePracticePreferences, } from "../lib/practicePreferences.js"; +import { api } from "../api/client.js"; export default function PracticePreferencePage() { const { user } = useUser(); @@ -19,6 +20,17 @@ export default function PracticePreferencePage() { useEffect(() => () => clearTimeout(toastTimerRef.current), []); + // 服务端是事实源:进入页面先对账,跨设备看到的都是同一份设置 + useEffect(() => { + if (!user?.userId) return undefined; + let cancelled = false; + api.getPracticePreferences(user.userId).then((serverPrefs) => { + if (cancelled) return; + setPrefs(savePracticePreferences(user.userId, serverPrefs)); + }).catch(() => {}); + return () => { cancelled = true; }; + }, [user?.userId]); + if (!user) return null; const summary = (value) => t("practicePrefs.summary", { @@ -29,6 +41,7 @@ export default function PracticePreferencePage() { const updatePrefs = (next) => { const saved = savePracticePreferences(user.userId, next); setPrefs(saved); + api.savePracticePreferences({ userId: user.userId, ...saved }).catch(console.error); setToast(t("practicePrefs.saved", { summary: summary(saved) })); clearTimeout(toastTimerRef.current); toastTimerRef.current = setTimeout(() => setToast(""), 1800);