Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` | 场景模式总览 / 公共题分类 / 混合练习 / 题目评测 | 场景链路变化时同步 |
Expand Down Expand Up @@ -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 |
Expand Down
Original file line number Diff line number Diff line change
@@ -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 表补行。
1 change: 1 addition & 0 deletions docs/changelog/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 渐进提示规格,分开代码交付、生产激活与产品试点验收。 |
Expand Down
2 changes: 2 additions & 0 deletions docs/design/schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
},
"avatarVersion": 1787555910000, // 每次上传更新,用于版本化地址刷新缓存
"sourceType": "human | ai_test", // 数据来源;普通用户默认 human,自动体验专用账号为 ai_test
"practicePreferences": { "level": "daily", "purpose": "travel" }, // 练习偏好(难度/目的):服务端事实源,跨设备一致;未设置时缺省
"createdAt": datetime,
"updatedAt": datetime // 修改资料后写入;历史用户可缺省
}
Expand All @@ -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(登录会话)
Expand Down
17 changes: 17 additions & 0 deletions docs/业务/9-练习偏好与难度选择.md
Original file line number Diff line number Diff line change
@@ -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,换设备会要求重新设置;现已服务端化,本地存储降级为缓存。
61 changes: 59 additions & 2 deletions server/routes/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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,
Expand Down
91 changes: 91 additions & 0 deletions server/tests/integration/test_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
5 changes: 5 additions & 0 deletions web/e2e/journey.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
}
Expand Down
5 changes: 5 additions & 0 deletions web/e2e/progressive.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
}
Expand Down
6 changes: 6 additions & 0 deletions web/src/api/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 120_000);

(async () => {

Check warning on line 70 in web/src/api/client.js

View workflow job for this annotation

GitHub Actions / Web tests + build (vitest + vite)

Async arrow function has a complexity of 22. Maximum allowed is 15
try {
const res = await fetch(`${BASE}/correct/stream`, {
method: "POST",
Expand Down Expand Up @@ -130,7 +130,7 @@
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 120_000);

(async () => {

Check warning on line 133 in web/src/api/client.js

View workflow job for this annotation

GitHub Actions / Web tests + build (vitest + vite)

Async arrow function has a complexity of 20. Maximum allowed is 15
try {
const res = await fetch(`${BASE}/correct/chat/stream`, {
method: "POST",
Expand Down Expand Up @@ -190,6 +190,12 @@
},
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=<slug> 用),失败由调用方展示不可用态
scenarioBySlug: (slug) => request(`/scenarios/by-slug/${encodeURIComponent(slug)}`),

Expand Down
32 changes: 32 additions & 0 deletions web/src/api/client.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@
});
});

describe("api/client 各方法 URL/method/body", () => {

Check warning on line 124 in web/src/api/client.test.js

View workflow job for this annotation

GitHub Actions / Web tests + build (vitest + vite)

Arrow function has too many lines (152). Maximum allowed is 120
let fetchMock;

beforeEach(() => {
Expand Down Expand Up @@ -449,3 +449,35 @@
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" });
});
});
5 changes: 5 additions & 0 deletions web/src/context/UserContext.jsx
Original file line number Diff line number Diff line change
@@ -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(() => {
Expand All @@ -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;
};
Expand Down
3 changes: 3 additions & 0 deletions web/src/pages/PracticePage.feedback.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
3 changes: 3 additions & 0 deletions web/src/pages/PracticePage.free.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
Loading
Loading