feat(settings): 添加通知偏好设置和后端 API 集成
This commit is contained in:
@@ -0,0 +1,201 @@
|
||||
"""update notification settings fields
|
||||
|
||||
Revision ID: 20260407_0001
|
||||
Revises: 20260403_0004
|
||||
Create Date: 2026-04-07 00:00:00
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = "20260407_0001"
|
||||
down_revision: Union[str, Sequence[str], None] = "202604030004"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute(
|
||||
"""
|
||||
create or replace function public.initialize_profile_and_points_on_signup()
|
||||
returns trigger
|
||||
language plpgsql
|
||||
security definer
|
||||
set search_path = public
|
||||
as $$
|
||||
declare
|
||||
v_username text;
|
||||
v_ledger_id uuid;
|
||||
v_event_id text;
|
||||
begin
|
||||
v_username := 'user_' || substring(md5(new.id::text || clock_timestamp()::text || random()::text) from 1 for 6);
|
||||
v_ledger_id := md5(new.id::text || 'ledger' || clock_timestamp()::text || random()::text)::uuid;
|
||||
v_event_id := 'register:' || new.id::text;
|
||||
|
||||
insert into public.profiles (id, username, avatar_url, bio, settings)
|
||||
values (
|
||||
new.id,
|
||||
v_username,
|
||||
null,
|
||||
null,
|
||||
jsonb_build_object(
|
||||
'version', 1,
|
||||
'preferences', jsonb_build_object(
|
||||
'interface_language', 'zh-CN',
|
||||
'ai_language', 'zh-CN',
|
||||
'timezone', 'Asia/Shanghai',
|
||||
'country', 'CN'
|
||||
),
|
||||
'privacy', jsonb_build_object('profile_visibility', 'public'),
|
||||
'notification', jsonb_build_object(
|
||||
'allow_notifications', true,
|
||||
'allow_vibration', true
|
||||
)
|
||||
)
|
||||
)
|
||||
on conflict (id) do nothing;
|
||||
|
||||
insert into public.user_points (
|
||||
user_id,
|
||||
balance,
|
||||
frozen_balance,
|
||||
lifetime_earned,
|
||||
lifetime_spent,
|
||||
version
|
||||
)
|
||||
values (new.id, 100, 0, 100, 0, 0)
|
||||
on conflict (user_id) do nothing;
|
||||
|
||||
insert into public.points_ledger (
|
||||
id,
|
||||
user_id,
|
||||
direction,
|
||||
amount,
|
||||
balance_after,
|
||||
change_type,
|
||||
biz_type,
|
||||
biz_id,
|
||||
event_id,
|
||||
operator_id,
|
||||
metadata
|
||||
)
|
||||
values (
|
||||
v_ledger_id,
|
||||
new.id,
|
||||
1,
|
||||
100,
|
||||
100,
|
||||
'register',
|
||||
null,
|
||||
null,
|
||||
v_event_id,
|
||||
null,
|
||||
jsonb_build_object(
|
||||
'schema_version', 1,
|
||||
'reason_code', 'REGISTER_WELCOME',
|
||||
'operator_type', 'system',
|
||||
'run_id', v_event_id,
|
||||
'request_id', null,
|
||||
'ext', jsonb_build_object('source', 'auth_signup')
|
||||
)
|
||||
)
|
||||
on conflict (user_id, event_id) do nothing;
|
||||
|
||||
return new;
|
||||
end;
|
||||
$$;
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute(
|
||||
"""
|
||||
create or replace function public.initialize_profile_and_points_on_signup()
|
||||
returns trigger
|
||||
language plpgsql
|
||||
security definer
|
||||
set search_path = public
|
||||
as $$
|
||||
declare
|
||||
v_username text;
|
||||
v_ledger_id uuid;
|
||||
v_event_id text;
|
||||
begin
|
||||
v_username := 'user_' || substring(md5(new.id::text || clock_timestamp()::text || random()::text) from 1 for 6);
|
||||
v_ledger_id := md5(new.id::text || 'ledger' || clock_timestamp()::text || random()::text)::uuid;
|
||||
v_event_id := 'register:' || new.id::text;
|
||||
|
||||
insert into public.profiles (id, username, avatar_url, bio, settings)
|
||||
values (
|
||||
new.id,
|
||||
v_username,
|
||||
null,
|
||||
null,
|
||||
jsonb_build_object(
|
||||
'version', 1,
|
||||
'preferences', jsonb_build_object(
|
||||
'interface_language', 'zh-CN',
|
||||
'ai_language', 'zh-CN',
|
||||
'timezone', 'Asia/Shanghai',
|
||||
'country', 'CN'
|
||||
),
|
||||
'privacy', jsonb_build_object('profile_visibility', 'public'),
|
||||
'notification', jsonb_build_object('push_enabled', true)
|
||||
)
|
||||
)
|
||||
on conflict (id) do nothing;
|
||||
|
||||
insert into public.user_points (
|
||||
user_id,
|
||||
balance,
|
||||
frozen_balance,
|
||||
lifetime_earned,
|
||||
lifetime_spent,
|
||||
version
|
||||
)
|
||||
values (new.id, 100, 0, 100, 0, 0)
|
||||
on conflict (user_id) do nothing;
|
||||
|
||||
insert into public.points_ledger (
|
||||
id,
|
||||
user_id,
|
||||
direction,
|
||||
amount,
|
||||
balance_after,
|
||||
change_type,
|
||||
biz_type,
|
||||
biz_id,
|
||||
event_id,
|
||||
operator_id,
|
||||
metadata
|
||||
)
|
||||
values (
|
||||
v_ledger_id,
|
||||
new.id,
|
||||
1,
|
||||
100,
|
||||
100,
|
||||
'register',
|
||||
null,
|
||||
null,
|
||||
v_event_id,
|
||||
null,
|
||||
jsonb_build_object(
|
||||
'schema_version', 1,
|
||||
'reason_code', 'REGISTER_WELCOME',
|
||||
'operator_type', 'system',
|
||||
'run_id', v_event_id,
|
||||
'request_id', null,
|
||||
'ext', jsonb_build_object('source', 'auth_signup')
|
||||
)
|
||||
)
|
||||
on conflict (user_id, event_id) do nothing;
|
||||
|
||||
return new;
|
||||
end;
|
||||
$$;
|
||||
"""
|
||||
)
|
||||
@@ -8,6 +8,7 @@ from v1.users.schemas import (
|
||||
AvatarUploadUrlResponse,
|
||||
ProfileResponse,
|
||||
UpdateProfileRequest,
|
||||
UpdateSettingsRequest,
|
||||
)
|
||||
from v1.users.service import UserService
|
||||
|
||||
@@ -30,6 +31,14 @@ async def update_my_profile(
|
||||
return await service.update_profile(payload)
|
||||
|
||||
|
||||
@router.patch("/me/settings", response_model=ProfileResponse)
|
||||
async def update_my_settings(
|
||||
payload: UpdateSettingsRequest,
|
||||
service: UserService = Depends(get_user_service),
|
||||
) -> ProfileResponse:
|
||||
return await service.update_settings(payload)
|
||||
|
||||
|
||||
@router.post("/me/avatar/upload-url", response_model=AvatarUploadUrlResponse)
|
||||
async def create_avatar_upload_url(
|
||||
payload: AvatarUploadUrlRequest,
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from schemas.shared.user import ProfileSettingsV1
|
||||
|
||||
|
||||
class ProfileResponse(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
@@ -14,7 +15,7 @@ class ProfileResponse(BaseModel):
|
||||
bio: str | None = None
|
||||
avatar_path: str | None = None
|
||||
avatar_url: str | None = None
|
||||
settings: dict[str, Any] = Field(default_factory=dict)
|
||||
settings: ProfileSettingsV1
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
@@ -26,6 +27,12 @@ class UpdateProfileRequest(BaseModel):
|
||||
avatar_path: str | None = None
|
||||
|
||||
|
||||
class UpdateSettingsRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
settings: ProfileSettingsV1
|
||||
|
||||
|
||||
class AvatarUploadUrlRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
@@ -11,12 +11,13 @@ from core.config.settings import config
|
||||
from core.auth.models import CurrentUser
|
||||
from core.http.errors import ApiProblemError, problem_payload
|
||||
from services.base.supabase import SupabaseService
|
||||
from schemas.shared.user import UserContext
|
||||
from schemas.shared.user import UserContext, parse_profile_settings
|
||||
from v1.users.repository import SQLAlchemyUserRepository
|
||||
from v1.users.schemas import (
|
||||
AvatarUploadUrlRequest,
|
||||
ProfileResponse,
|
||||
UpdateProfileRequest,
|
||||
UpdateSettingsRequest,
|
||||
)
|
||||
|
||||
|
||||
@@ -40,7 +41,9 @@ class UserService:
|
||||
email=self.current_user.email,
|
||||
avatar_url=profile.avatar_url if profile is not None else None,
|
||||
bio=profile.bio if profile is not None else None,
|
||||
settings=profile.settings if profile is not None else None,
|
||||
settings=parse_profile_settings(profile.settings)
|
||||
if profile is not None
|
||||
else None,
|
||||
)
|
||||
|
||||
async def get_profile(self) -> ProfileResponse:
|
||||
@@ -62,7 +65,7 @@ class UserService:
|
||||
bio=profile.bio,
|
||||
avatar_path=profile.avatar_url,
|
||||
avatar_url=avatar_url,
|
||||
settings=profile.settings,
|
||||
settings=parse_profile_settings(profile.settings),
|
||||
updated_at=profile.updated_at,
|
||||
)
|
||||
|
||||
@@ -122,6 +125,24 @@ class UserService:
|
||||
await self.repository.save()
|
||||
return await self.get_profile()
|
||||
|
||||
async def update_settings(self, payload: UpdateSettingsRequest) -> ProfileResponse:
|
||||
profile = await self.repository.get_profile_by_user_id(
|
||||
user_id=self.current_user.id
|
||||
)
|
||||
if profile is None:
|
||||
raise ApiProblemError(
|
||||
status_code=404,
|
||||
detail=problem_payload(
|
||||
code="PROFILE_NOT_FOUND",
|
||||
detail="Profile not found",
|
||||
),
|
||||
)
|
||||
|
||||
profile.settings = payload.settings.model_dump(mode="json")
|
||||
|
||||
await self.repository.save()
|
||||
return await self.get_profile()
|
||||
|
||||
async def create_avatar_upload_url(
|
||||
self, payload: AvatarUploadUrlRequest
|
||||
) -> dict[str, str | int]:
|
||||
|
||||
Reference in New Issue
Block a user