feat(account): add account settings sync contract
This commit is contained in:
parent
6c0f6dac53
commit
95892d25ec
|
|
@ -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"]
|
||||||
|
|
@ -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",
|
||||||
|
]
|
||||||
|
|
@ -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"]
|
||||||
|
|
@ -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
|
||||||
|
|
@ -1,3 +1,6 @@
|
||||||
|
import { apiRequest } from "@/api/base";
|
||||||
|
import { getAuthToken } from "@/lib/api";
|
||||||
|
|
||||||
export type AccountLanguage = "zh-CN" | "en-US";
|
export type AccountLanguage = "zh-CN" | "en-US";
|
||||||
export type AccountAnswerDepth = "concise" | "balanced" | "deep";
|
export type AccountAnswerDepth = "concise" | "balanced" | "deep";
|
||||||
export type AccountEvidenceDisplay = "summary" | "inline" | "full";
|
export type AccountEvidenceDisplay = "summary" | "inline" | "full";
|
||||||
|
|
@ -39,6 +42,13 @@ export interface AccountSettingsSeed {
|
||||||
}
|
}
|
||||||
|
|
||||||
const STORAGE_KEY = "pmflow:account-settings:v1";
|
const STORAGE_KEY = "pmflow:account-settings:v1";
|
||||||
|
const ACCOUNT_SETTINGS_ENDPOINT = "/account-settings/me";
|
||||||
|
|
||||||
|
interface ApiEnvelope<T> {
|
||||||
|
code: number;
|
||||||
|
message: string;
|
||||||
|
data: T | null;
|
||||||
|
}
|
||||||
|
|
||||||
export const accountNotificationLabels: Record<AccountNotificationKey, { title: string; detail: string }> = {
|
export const accountNotificationLabels: Record<AccountNotificationKey, { title: string; detail: string }> = {
|
||||||
pendingConfirmation: {
|
pendingConfirmation: {
|
||||||
|
|
@ -93,6 +103,11 @@ function isBrowserStorageAvailable(): boolean {
|
||||||
return typeof window !== "undefined" && typeof window.localStorage !== "undefined";
|
return typeof window !== "undefined" && typeof window.localStorage !== "undefined";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function canUseAccountBackend(): boolean {
|
||||||
|
if (typeof window === "undefined") return false;
|
||||||
|
return Boolean(getAuthToken());
|
||||||
|
}
|
||||||
|
|
||||||
function readStoredAccountSettings(): Partial<AccountSettings> | null {
|
function readStoredAccountSettings(): Partial<AccountSettings> | null {
|
||||||
if (!isBrowserStorageAvailable()) return null;
|
if (!isBrowserStorageAvailable()) return null;
|
||||||
const raw = window.localStorage.getItem(STORAGE_KEY);
|
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||||||
|
|
@ -112,6 +127,14 @@ function persistAccountSettings(settings: AccountSettings): void {
|
||||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
|
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<AccountSettings> | null): AccountSettings {
|
function mergeAccountSettings(base: AccountSettings, stored: Partial<AccountSettings> | null): AccountSettings {
|
||||||
if (!stored) return base;
|
if (!stored) return base;
|
||||||
return {
|
return {
|
||||||
|
|
@ -134,10 +157,44 @@ function mergeAccountSettings(base: AccountSettings, stored: Partial<AccountSett
|
||||||
|
|
||||||
export async function getAccountSettings(seed?: AccountSettingsSeed): Promise<AccountSettings> {
|
export async function getAccountSettings(seed?: AccountSettingsSeed): Promise<AccountSettings> {
|
||||||
const defaults = createDefaultAccountSettings(seed);
|
const defaults = createDefaultAccountSettings(seed);
|
||||||
return mergeAccountSettings(defaults, readStoredAccountSettings());
|
const localSettings = mergeAccountSettings(defaults, readStoredAccountSettings());
|
||||||
|
if (!canUseAccountBackend()) return localSettings;
|
||||||
|
try {
|
||||||
|
const response = await apiRequest<ApiEnvelope<AccountSettings>>(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<AccountSettings> {
|
export async function updateAccountSettings(nextSettings: AccountSettings): Promise<AccountSettings> {
|
||||||
|
if (canUseAccountBackend()) {
|
||||||
|
try {
|
||||||
|
const response = await apiRequest<ApiEnvelope<AccountSettings>>(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 = {
|
const persisted = {
|
||||||
...nextSettings,
|
...nextSettings,
|
||||||
saveScope: "local" as const,
|
saveScope: "local" as const,
|
||||||
|
|
|
||||||
|
|
@ -3,14 +3,17 @@ import { createDefaultAccountSettings, getAccountSettings, updateAccountSettings
|
||||||
|
|
||||||
describe("account settings api", () => {
|
describe("account settings api", () => {
|
||||||
const storageSetItem = Storage.prototype.setItem;
|
const storageSetItem = Storage.prototype.setItem;
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
window.localStorage.clear();
|
window.localStorage.clear();
|
||||||
Storage.prototype.setItem = storageSetItem;
|
Storage.prototype.setItem = storageSetItem;
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
Storage.prototype.setItem = storageSetItem;
|
Storage.prototype.setItem = storageSetItem;
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -60,4 +63,55 @@ describe("account settings api", () => {
|
||||||
|
|
||||||
await expect(updateAccountSettings(createDefaultAccountSettings())).rejects.toThrow("storage denied");
|
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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue