From 95892d25ec852b095d767d7267db73c041ea895f Mon Sep 17 00:00:00 2001 From: Albert Date: Sun, 26 Apr 2026 10:56:07 +0800 Subject: [PATCH] feat(account): add account settings sync contract --- .../app/api/account_settings_api/__init__.py | 46 +++++ backend/app/schemas/account_settings.py | 72 ++++++++ .../app/services/account_settings_service.py | 148 ++++++++++++++++ backend/tests/test_account_settings_api.py | 158 ++++++++++++++++++ frontend/src/api/accountSettings.ts | 59 ++++++- .../account-settings/accountSettings.test.ts | 54 ++++++ 6 files changed, 536 insertions(+), 1 deletion(-) create mode 100644 backend/app/api/account_settings_api/__init__.py create mode 100644 backend/app/schemas/account_settings.py create mode 100644 backend/app/services/account_settings_service.py create mode 100644 backend/tests/test_account_settings_api.py diff --git a/backend/app/api/account_settings_api/__init__.py b/backend/app/api/account_settings_api/__init__.py new file mode 100644 index 00000000..618148e8 --- /dev/null +++ b/backend/app/api/account_settings_api/__init__.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends +from fastapi.responses import JSONResponse +from sqlalchemy.ext.asyncio import AsyncSession + +from ...core.database import get_db +from ...core.response import error_response, success_response +from ...dependencies import get_current_user +from ...models import User +from ...schemas.account_settings import AccountSettingsUpdateRequest +from ...services.account_settings_service import AccountSettingsService + +router = APIRouter(prefix="/account-settings", tags=["account-settings"]) + + +def _service(db: AsyncSession, current_user: User) -> AccountSettingsService: + return AccountSettingsService(db, tenant_id=int(current_user.tenant_id)) + + +@router.get("/me") +async def get_my_account_settings( + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + settings = await _service(db, current_user).get_settings(user=current_user) + return success_response(settings.model_dump(mode="json")) + + +@router.put("/me") +async def update_my_account_settings( + body: AccountSettingsUpdateRequest, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + service = _service(db, current_user) + try: + settings = await service.update_settings(user=current_user, body=body) + except ValueError as exc: + await db.rollback() + return JSONResponse(status_code=400, content=error_response(400, str(exc))) + await db.commit() + return success_response(settings.model_dump(mode="json")) + + +__all__ = ["router"] diff --git a/backend/app/schemas/account_settings.py b/backend/app/schemas/account_settings.py new file mode 100644 index 00000000..14d36330 --- /dev/null +++ b/backend/app/schemas/account_settings.py @@ -0,0 +1,72 @@ +"""Account Settings API schemas.""" + +from __future__ import annotations + +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field + + +AccountLanguage = Literal["zh-CN", "en-US"] +AccountAnswerDepth = Literal["concise", "balanced", "deep"] +AccountEvidenceDisplay = Literal["summary", "inline", "full"] +AccountNotificationKey = Literal[ + "pendingConfirmation", + "executionWriteback", + "releaseGate", + "failureAlert", +] +AccountSettingsSaveScope = Literal["local", "account"] + + +class AccountProfileSettings(BaseModel): + displayName: str = Field(min_length=1, max_length=100) + roleTitle: str = Field(min_length=1, max_length=100) + email: str = Field(default="", max_length=255) + defaultLanguage: AccountLanguage = "zh-CN" + + +class AccountAiPreferenceSettings(BaseModel): + answerDepth: AccountAnswerDepth = "balanced" + evidenceDisplay: AccountEvidenceDisplay = "inline" + + +class AccountNotificationPreference(BaseModel): + enabled: bool = True + desktop: bool = True + emailDigest: bool = False + + +class AccountNotificationSettings(BaseModel): + pendingConfirmation: AccountNotificationPreference + executionWriteback: AccountNotificationPreference + releaseGate: AccountNotificationPreference + failureAlert: AccountNotificationPreference + + +class AccountSettingsUpdateRequest(BaseModel): + profile: AccountProfileSettings + aiPreferences: AccountAiPreferenceSettings + notifications: AccountNotificationSettings + + +class AccountSettingsResponse(AccountSettingsUpdateRequest): + model_config = ConfigDict(populate_by_name=True) + + saveScope: AccountSettingsSaveScope = "account" + updatedAt: str + + +__all__ = [ + "AccountAiPreferenceSettings", + "AccountAnswerDepth", + "AccountEvidenceDisplay", + "AccountLanguage", + "AccountNotificationKey", + "AccountNotificationPreference", + "AccountNotificationSettings", + "AccountProfileSettings", + "AccountSettingsResponse", + "AccountSettingsSaveScope", + "AccountSettingsUpdateRequest", +] diff --git a/backend/app/services/account_settings_service.py b/backend/app/services/account_settings_service.py new file mode 100644 index 00000000..f339aa1a --- /dev/null +++ b/backend/app/services/account_settings_service.py @@ -0,0 +1,148 @@ +"""Account Settings persistence service.""" + +from __future__ import annotations + +from typing import Any + +from sqlalchemy import and_, select +from sqlalchemy.ext.asyncio import AsyncSession + +from ..models import Integration +from ..schemas.account_settings import ( + AccountAiPreferenceSettings, + AccountNotificationPreference, + AccountNotificationSettings, + AccountProfileSettings, + AccountSettingsResponse, + AccountSettingsUpdateRequest, +) +from ..utils.timezone import now_cn_naive + +INTEGRATION_TYPE = "account_settings_preferences" + + +class AccountSettingsService: + def __init__(self, db: AsyncSession, *, tenant_id: int) -> None: + self.db = db + self.tenant_id = int(tenant_id) + + async def get_settings(self, *, user: Any) -> AccountSettingsResponse: + bundle = await self._get_bundle() + user_key = self._user_key(user) + stored = self._settings_by_user(bundle).get(user_key) + if isinstance(stored, dict): + return AccountSettingsResponse.model_validate(stored) + return self._default_settings(user=user) + + async def update_settings(self, *, user: Any, body: AccountSettingsUpdateRequest) -> AccountSettingsResponse: + row, bundle = await self._get_or_init_bundle() + user_key = self._user_key(user) + settings = AccountSettingsResponse( + profile=body.profile, + aiPreferences=body.aiPreferences, + notifications=body.notifications, + saveScope="account", + updatedAt=now_cn_naive().isoformat(), + ) + users = self._settings_by_user(bundle) + users[user_key] = settings.model_dump(mode="json") + bundle["users"] = users + bundle["version"] = 1 + row.config = bundle + await self.db.flush() + return settings + + async def _get_bundle(self) -> dict[str, Any]: + row = ( + await self.db.execute( + select(Integration).where( + and_( + Integration.tenant_id == self.tenant_id, + Integration.type == INTEGRATION_TYPE, + ) + ) + ) + ).scalar_one_or_none() + if row is None: + return {"version": 1, "users": {}} + return self._normalize_bundle(row.config) + + async def _get_or_init_bundle(self) -> tuple[Integration, dict[str, Any]]: + row = ( + await self.db.execute( + select(Integration).where( + and_( + Integration.tenant_id == self.tenant_id, + Integration.type == INTEGRATION_TYPE, + ) + ) + ) + ).scalar_one_or_none() + if row is None: + row = Integration( + tenant_id=self.tenant_id, + type=INTEGRATION_TYPE, + is_active=True, + config={"version": 1, "users": {}}, + ) + self.db.add(row) + await self.db.flush() + bundle = self._normalize_bundle(row.config) + if bundle != row.config: + row.config = bundle + await self.db.flush() + return row, bundle + + @staticmethod + def _normalize_bundle(config: Any) -> dict[str, Any]: + bundle = dict(config or {}) if isinstance(config, dict) else {} + if not isinstance(bundle.get("users"), dict): + bundle["users"] = {} + if not isinstance(bundle.get("version"), int): + bundle["version"] = 1 + return bundle + + @staticmethod + def _settings_by_user(bundle: dict[str, Any]) -> dict[str, Any]: + users = bundle.get("users") + return dict(users) if isinstance(users, dict) else {} + + @staticmethod + def _user_key(user: Any) -> str: + return str(getattr(user, "id", "") or "unknown") + + @staticmethod + def _default_settings(*, user: Any) -> AccountSettingsResponse: + role = str(getattr(user, "role", "") or "").strip() + role_title = { + "admin": "管理员", + "manager": "项目负责人", + "ADMIN": "管理员", + "MANAGER": "项目负责人", + }.get(role, role or "产品负责人") + display_name = ( + str(getattr(user, "name", "") or "").strip() + or str(getattr(user, "username", "") or "").strip() + or "PMFlow 用户" + ) + email = str(getattr(user, "email", "") or "").strip() + return AccountSettingsResponse( + profile=AccountProfileSettings( + displayName=display_name, + roleTitle=role_title, + email=email, + defaultLanguage="zh-CN", + ), + aiPreferences=AccountAiPreferenceSettings(answerDepth="balanced", evidenceDisplay="inline"), + notifications=AccountNotificationSettings( + pendingConfirmation=AccountNotificationPreference(enabled=True, desktop=True, emailDigest=False), + executionWriteback=AccountNotificationPreference(enabled=True, desktop=True, emailDigest=False), + releaseGate=AccountNotificationPreference(enabled=True, desktop=True, emailDigest=True), + failureAlert=AccountNotificationPreference(enabled=True, desktop=True, emailDigest=True), + ), + saveScope="account", + updatedAt=now_cn_naive().isoformat(), + ) + + +__all__ = ["AccountSettingsService", "INTEGRATION_TYPE"] diff --git a/backend/tests/test_account_settings_api.py b/backend/tests/test_account_settings_api.py new file mode 100644 index 00000000..79074af4 --- /dev/null +++ b/backend/tests/test_account_settings_api.py @@ -0,0 +1,158 @@ +from __future__ import annotations + +import os +from types import SimpleNamespace + +import pytest +import pytest_asyncio +from fastapi import FastAPI +from httpx import ASGITransport, AsyncClient +from sqlalchemy import JSON, select +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine + +os.environ.setdefault("DATABASE_URL", "postgresql+asyncpg://tester:tester@localhost:5432/pmflow_test") + +from app.api import account_settings_api # noqa: E402 +from app.core.database import get_db # noqa: E402 +from app.dependencies.auth import get_current_user # noqa: E402 +from app.models.base import Base # noqa: E402 +from app.models.integration import Integration # noqa: E402 +from app.services.account_settings_service import INTEGRATION_TYPE # noqa: E402 + + +def _use_sqlite_json_types(*tables) -> None: + for table in tables: + for column in table.columns: + if column.type.__class__.__name__ in {"JSONB", "ARRAY", "Vector"}: + column.type = JSON() + + +@pytest_asyncio.fixture +async def db_session(): + engine = create_async_engine("sqlite+aiosqlite:///:memory:") + tables = [Integration.__table__] + _use_sqlite_json_types(*tables) + async with engine.begin() as conn: + await conn.run_sync(lambda sync_conn: Base.metadata.create_all(sync_conn, tables=tables)) + maker = async_sessionmaker(engine, expire_on_commit=False) + async with maker() as session: + yield session + await engine.dispose() + + +def _current_user(*, user_id: int = 7, tenant_id: int = 1): + return SimpleNamespace( + id=user_id, + tenant_id=tenant_id, + role="MEMBER", + username=f"tester-{tenant_id}-{user_id}", + email=f"tester-{tenant_id}-{user_id}@example.com", + name="陈雨", + ) + + +def _build_app(db_session, current_user) -> FastAPI: + app = FastAPI() + app.include_router(account_settings_api.router) + + async def _fake_get_db(): + yield db_session + + app.dependency_overrides[get_db] = _fake_get_db + app.dependency_overrides[get_current_user] = lambda: current_user + return app + + +def _settings_payload(**overrides): + payload = { + "profile": { + "displayName": "陈雨", + "roleTitle": "独立开发者", + "email": "yu@example.com", + "defaultLanguage": "en-US", + }, + "aiPreferences": { + "answerDepth": "deep", + "evidenceDisplay": "full", + }, + "notifications": { + "pendingConfirmation": {"enabled": True, "desktop": True, "emailDigest": False}, + "executionWriteback": {"enabled": True, "desktop": False, "emailDigest": False}, + "releaseGate": {"enabled": True, "desktop": True, "emailDigest": True}, + "failureAlert": {"enabled": False, "desktop": False, "emailDigest": False}, + }, + } + payload.update(overrides) + return payload + + +@pytest.mark.asyncio +async def test_get_account_settings_returns_defaults_from_current_user(db_session) -> None: + app = _build_app(db_session, _current_user()) + + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + response = await client.get("/account-settings/me") + + assert response.status_code == 200 + body = response.json() + assert body["code"] == 0 + assert body["data"]["profile"]["displayName"] == "陈雨" + assert body["data"]["profile"]["defaultLanguage"] == "zh-CN" + assert body["data"]["aiPreferences"]["answerDepth"] == "balanced" + assert body["data"]["notifications"]["pendingConfirmation"]["enabled"] is True + assert body["data"]["saveScope"] == "account" + + +@pytest.mark.asyncio +async def test_put_account_settings_persists_user_preferences(db_session) -> None: + app = _build_app(db_session, _current_user()) + + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + update = await client.put("/account-settings/me", json=_settings_payload()) + restored = await client.get("/account-settings/me") + + assert update.status_code == 200 + assert update.json()["data"]["saveScope"] == "account" + assert restored.status_code == 200 + assert restored.json()["data"]["profile"]["defaultLanguage"] == "en-US" + assert restored.json()["data"]["aiPreferences"]["answerDepth"] == "deep" + assert restored.json()["data"]["aiPreferences"]["evidenceDisplay"] == "full" + assert restored.json()["data"]["notifications"]["failureAlert"]["enabled"] is False + + row = ( + await db_session.execute( + select(Integration).where( + Integration.tenant_id == 1, + Integration.type == INTEGRATION_TYPE, + ) + ) + ).scalar_one() + assert row.config["users"]["7"]["profile"]["displayName"] == "陈雨" + + +@pytest.mark.asyncio +async def test_account_settings_are_user_scoped(db_session) -> None: + user7_app = _build_app(db_session, _current_user(user_id=7)) + user8_app = _build_app(db_session, _current_user(user_id=8)) + + async with AsyncClient(transport=ASGITransport(app=user7_app), base_url="http://test") as client: + update = await client.put("/account-settings/me", json=_settings_payload()) + + async with AsyncClient(transport=ASGITransport(app=user8_app), base_url="http://test") as client: + restored = await client.get("/account-settings/me") + + assert update.status_code == 200 + assert restored.status_code == 200 + assert restored.json()["data"]["profile"]["defaultLanguage"] == "zh-CN" + assert restored.json()["data"]["aiPreferences"]["answerDepth"] == "balanced" + + +@pytest.mark.asyncio +async def test_account_settings_reject_invalid_notification_key(db_session) -> None: + app = _build_app(db_session, _current_user()) + payload = _settings_payload(notifications={"pendingConfirmation": {"enabled": True, "desktop": True, "emailDigest": False}}) + + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + response = await client.put("/account-settings/me", json=payload) + + assert response.status_code == 422 diff --git a/frontend/src/api/accountSettings.ts b/frontend/src/api/accountSettings.ts index f05735b0..a073c766 100644 --- a/frontend/src/api/accountSettings.ts +++ b/frontend/src/api/accountSettings.ts @@ -1,3 +1,6 @@ +import { apiRequest } from "@/api/base"; +import { getAuthToken } from "@/lib/api"; + export type AccountLanguage = "zh-CN" | "en-US"; export type AccountAnswerDepth = "concise" | "balanced" | "deep"; export type AccountEvidenceDisplay = "summary" | "inline" | "full"; @@ -39,6 +42,13 @@ export interface AccountSettingsSeed { } const STORAGE_KEY = "pmflow:account-settings:v1"; +const ACCOUNT_SETTINGS_ENDPOINT = "/account-settings/me"; + +interface ApiEnvelope { + code: number; + message: string; + data: T | null; +} export const accountNotificationLabels: Record = { pendingConfirmation: { @@ -93,6 +103,11 @@ function isBrowserStorageAvailable(): boolean { return typeof window !== "undefined" && typeof window.localStorage !== "undefined"; } +function canUseAccountBackend(): boolean { + if (typeof window === "undefined") return false; + return Boolean(getAuthToken()); +} + function readStoredAccountSettings(): Partial | null { if (!isBrowserStorageAvailable()) return null; const raw = window.localStorage.getItem(STORAGE_KEY); @@ -112,6 +127,14 @@ function persistAccountSettings(settings: AccountSettings): void { window.localStorage.setItem(STORAGE_KEY, JSON.stringify(settings)); } +function cacheAccountSettingsSnapshot(settings: AccountSettings): void { + try { + persistAccountSettings(settings); + } catch { + // Backend account save is the source of truth; local snapshot failure should not downgrade it. + } +} + function mergeAccountSettings(base: AccountSettings, stored: Partial | null): AccountSettings { if (!stored) return base; return { @@ -134,10 +157,44 @@ function mergeAccountSettings(base: AccountSettings, stored: Partial { const defaults = createDefaultAccountSettings(seed); - return mergeAccountSettings(defaults, readStoredAccountSettings()); + const localSettings = mergeAccountSettings(defaults, readStoredAccountSettings()); + if (!canUseAccountBackend()) return localSettings; + try { + const response = await apiRequest>(ACCOUNT_SETTINGS_ENDPOINT, { method: "GET" }); + if (response.data) { + const accountSettings = mergeAccountSettings(defaults, response.data); + cacheAccountSettingsSnapshot(accountSettings); + return accountSettings; + } + } catch { + return localSettings; + } + return localSettings; } export async function updateAccountSettings(nextSettings: AccountSettings): Promise { + if (canUseAccountBackend()) { + try { + const response = await apiRequest>(ACCOUNT_SETTINGS_ENDPOINT, { + method: "PUT", + body: JSON.stringify({ + profile: nextSettings.profile, + aiPreferences: nextSettings.aiPreferences, + notifications: nextSettings.notifications, + }), + }); + if (response.data) { + const saved = { + ...response.data, + saveScope: "account" as const, + }; + cacheAccountSettingsSnapshot(saved); + return saved; + } + } catch { + // Fall back to honest local-only persistence below. + } + } const persisted = { ...nextSettings, saveScope: "local" as const, diff --git a/frontend/src/features/account-settings/accountSettings.test.ts b/frontend/src/features/account-settings/accountSettings.test.ts index 04b86e68..3600ed13 100644 --- a/frontend/src/features/account-settings/accountSettings.test.ts +++ b/frontend/src/features/account-settings/accountSettings.test.ts @@ -3,14 +3,17 @@ import { createDefaultAccountSettings, getAccountSettings, updateAccountSettings describe("account settings api", () => { const storageSetItem = Storage.prototype.setItem; + const originalFetch = globalThis.fetch; beforeEach(() => { window.localStorage.clear(); Storage.prototype.setItem = storageSetItem; + globalThis.fetch = originalFetch; }); afterEach(() => { Storage.prototype.setItem = storageSetItem; + globalThis.fetch = originalFetch; vi.restoreAllMocks(); }); @@ -60,4 +63,55 @@ describe("account settings api", () => { await expect(updateAccountSettings(createDefaultAccountSettings())).rejects.toThrow("storage denied"); }); + + it("uses account sync when an authenticated backend contract is available", async () => { + window.localStorage.setItem("pmflow_token", "token"); + const accountSaved = { + ...createDefaultAccountSettings({ displayName: "陈雨", email: "yu@example.com" }), + saveScope: "account" as const, + aiPreferences: { answerDepth: "deep" as const, evidenceDisplay: "full" as const }, + updatedAt: "2026-04-26T10:00:00", + }; + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ code: 0, message: "success", data: accountSaved }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }) + ); + globalThis.fetch = fetchMock; + + const saved = await updateAccountSettings(accountSaved); + + expect(saved.saveScope).toBe("account"); + expect(saved.aiPreferences.answerDepth).toBe("deep"); + expect(fetchMock).toHaveBeenCalledWith( + "/api/account-settings/me", + expect.objectContaining({ + method: "PUT", + }) + ); + }); + + it("falls back to honest local persistence when backend sync fails", async () => { + window.localStorage.setItem("pmflow_token", "token"); + vi.spyOn(console, "error").mockImplementation(() => undefined); + globalThis.fetch = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ code: 500, message: "backend unavailable", data: null }), { + status: 500, + headers: { "Content-Type": "application/json" }, + }) + ); + const defaults = createDefaultAccountSettings({ displayName: "陈雨" }); + + const saved = await updateAccountSettings({ + ...defaults, + aiPreferences: { answerDepth: "deep", evidenceDisplay: "inline" }, + }); + + expect(saved.saveScope).toBe("local"); + expect(saved.aiPreferences.answerDepth).toBe("deep"); + const restored = await getAccountSettings({ displayName: "陈雨" }); + expect(restored.saveScope).toBe("local"); + expect(restored.aiPreferences.answerDepth).toBe("deep"); + }); });