feat(account): add account settings sync contract

This commit is contained in:
Albert 2026-04-26 10:56:07 +08:00
parent 6c0f6dac53
commit 95892d25ec
6 changed files with 536 additions and 1 deletions

View File

@ -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"]

View File

@ -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",
]

View File

@ -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"]

View File

@ -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

View File

@ -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,

View File

@ -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");
});
}); });