feat(points): 实现积分流水列表功能
- 后端新增 GET /api/v1/points/ledger 接口 - 前端新增积分流水列表页面 - 积分中心添加「查看流水」入口 - 重命名 AccountDeleteScreen 为 AccountDataScreen - 流水列表支持分页加载和空状态展示
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from uuid import UUID
|
||||
|
||||
@@ -209,3 +210,19 @@ class PointsRepository:
|
||||
stmt = select(Profile.settings).where(Profile.id == user_id).limit(1)
|
||||
row = (await self._session.execute(stmt)).scalar_one_or_none()
|
||||
return row
|
||||
|
||||
async def list_ledger(
|
||||
self,
|
||||
*,
|
||||
user_id: UUID,
|
||||
limit: int,
|
||||
cursor: datetime | None = None,
|
||||
) -> tuple[list[PointsLedger], bool]:
|
||||
stmt = select(PointsLedger).where(PointsLedger.user_id == user_id)
|
||||
if cursor is not None:
|
||||
stmt = stmt.where(PointsLedger.created_at < cursor)
|
||||
stmt = stmt.order_by(PointsLedger.created_at.desc()).limit(limit + 1)
|
||||
rows = list((await self._session.execute(stmt)).scalars().all())
|
||||
has_more = len(rows) > limit
|
||||
items = rows[:limit]
|
||||
return (items, has_more)
|
||||
|
||||
@@ -1,18 +1,42 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
|
||||
from core.auth.models import CurrentUser
|
||||
from core.http.errors import ApiProblemError, problem_payload
|
||||
from v1.points.dependencies import get_points_service
|
||||
from v1.points.schemas import PackagesResponse, PackageInfo, PointsBalanceResponse
|
||||
from v1.points.schemas import (
|
||||
PackagesResponse,
|
||||
PackageInfo,
|
||||
PointsBalanceResponse,
|
||||
LedgerListResponse,
|
||||
LedgerItem,
|
||||
)
|
||||
from v1.points.service import PointsService
|
||||
from v1.users.dependencies import get_current_user
|
||||
|
||||
router = APIRouter(prefix="/points", tags=["points"])
|
||||
|
||||
|
||||
def _parse_cursor(cursor: str | None) -> datetime | None:
|
||||
if cursor is None:
|
||||
return None
|
||||
try:
|
||||
return datetime.fromisoformat(cursor.replace("Z", "+00:00"))
|
||||
except ValueError as exc:
|
||||
raise ApiProblemError(
|
||||
status_code=422,
|
||||
detail=problem_payload(
|
||||
code="POINTS_INVALID_CURSOR",
|
||||
detail="Points ledger cursor must be an ISO 8601 datetime",
|
||||
params={"cursor": cursor},
|
||||
),
|
||||
) from exc
|
||||
|
||||
|
||||
@router.get("/balance", response_model=PointsBalanceResponse)
|
||||
async def get_points_balance(
|
||||
service: Annotated[PointsService, Depends(get_points_service)],
|
||||
@@ -55,3 +79,32 @@ async def get_available_packages(
|
||||
for pkg in result.packages
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@router.get("/ledger", response_model=LedgerListResponse)
|
||||
async def get_points_ledger(
|
||||
service: Annotated[PointsService, Depends(get_points_service)],
|
||||
current_user: Annotated[CurrentUser, Depends(get_current_user)],
|
||||
limit: Annotated[int, Query(ge=1, le=100)] = 20,
|
||||
cursor: str | None = None,
|
||||
) -> LedgerListResponse:
|
||||
items, next_cursor, has_more = await service.get_ledger_list(
|
||||
user_id=current_user.id,
|
||||
limit=limit,
|
||||
cursor=_parse_cursor(cursor),
|
||||
)
|
||||
return LedgerListResponse(
|
||||
items=[
|
||||
LedgerItem(
|
||||
id=item.id,
|
||||
direction=item.direction,
|
||||
amount=item.amount,
|
||||
balanceAfter=item.balance_after,
|
||||
changeType=item.change_type,
|
||||
createdAt=item.created_at,
|
||||
)
|
||||
for item in items
|
||||
],
|
||||
nextCursor=next_cursor,
|
||||
hasMore=has_more,
|
||||
)
|
||||
|
||||
@@ -23,7 +23,6 @@ class PackageInfo(BaseModel):
|
||||
alias="appStoreProductId", min_length=1, max_length=256
|
||||
)
|
||||
type: Literal["starter", "regular"]
|
||||
price: float = Field(ge=0)
|
||||
credits: int = Field(ge=1)
|
||||
is_starter: bool = Field(alias="isStarter")
|
||||
starter_eligible: bool = Field(alias="starterEligible")
|
||||
@@ -33,6 +32,23 @@ class PackageInfo(BaseModel):
|
||||
class PackagesResponse(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True, serialize_by_alias=True)
|
||||
|
||||
region: str = Field(min_length=1, max_length=8)
|
||||
currency: str = Field(min_length=1, max_length=8)
|
||||
packages: list[PackageInfo]
|
||||
|
||||
|
||||
class LedgerItem(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True, serialize_by_alias=True)
|
||||
|
||||
id: str
|
||||
direction: int
|
||||
amount: int = Field(ge=1)
|
||||
balance_after: int = Field(alias="balanceAfter", ge=0)
|
||||
change_type: str = Field(alias="changeType")
|
||||
created_at: str = Field(alias="createdAt")
|
||||
|
||||
|
||||
class LedgerListResponse(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True, serialize_by_alias=True)
|
||||
|
||||
items: list[LedgerItem]
|
||||
next_cursor: str | None = Field(alias="nextCursor", default=None)
|
||||
has_more: bool = Field(alias="hasMore")
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
import hashlib
|
||||
import hmac
|
||||
from typing import TYPE_CHECKING, Literal
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from core.config.packages import (
|
||||
PackageType,
|
||||
get_packages_config_for_region,
|
||||
)
|
||||
from core.config.settings import config
|
||||
from core.http.errors import ApiProblemError, problem_payload
|
||||
from schemas.domain.points import (
|
||||
@@ -22,9 +19,9 @@ from schemas.domain.points import (
|
||||
)
|
||||
from schemas.enums import PointsBizType, PointsChangeType, PointsOperatorType
|
||||
from schemas.domain.points import ApplyPointsChangeCommand
|
||||
from schemas.shared.user import parse_profile_settings
|
||||
from v1.payments.service import _load_product_mappings
|
||||
from v1.points.repository import PointsRepository
|
||||
from v1.points.schemas import LedgerItem
|
||||
|
||||
if TYPE_CHECKING:
|
||||
pass
|
||||
@@ -69,8 +66,7 @@ class RegisterBonusResult:
|
||||
class PackageInfoResult:
|
||||
product_code: str
|
||||
app_store_product_id: str
|
||||
type: PackageType
|
||||
price: float
|
||||
type: Literal["starter", "regular"]
|
||||
credits: int
|
||||
sort_order: int
|
||||
is_starter: bool
|
||||
@@ -79,8 +75,6 @@ class PackageInfoResult:
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PackagesResult:
|
||||
region: str
|
||||
currency: str
|
||||
packages: list[PackageInfoResult]
|
||||
|
||||
|
||||
@@ -449,11 +443,6 @@ class PointsService:
|
||||
user_id: UUID,
|
||||
user_email: str,
|
||||
) -> PackagesResult:
|
||||
settings_raw = await self._repository.get_profile_settings(user_id=user_id)
|
||||
settings = parse_profile_settings(settings_raw)
|
||||
country = settings.preferences.country
|
||||
|
||||
pkg_config = get_packages_config_for_region(country)
|
||||
normalized_email = self._normalize_email(user_email)
|
||||
|
||||
has_starter = False
|
||||
@@ -466,32 +455,59 @@ class PointsService:
|
||||
product_mappings = _load_product_mappings()
|
||||
|
||||
packages: list[PackageInfoResult] = []
|
||||
for pkg in pkg_config.packages:
|
||||
if not pkg.enabled:
|
||||
for product_code, mapping in product_mappings.items():
|
||||
if not mapping.enabled:
|
||||
continue
|
||||
if pkg.type == PackageType.STARTER and has_starter:
|
||||
pkg_type: Literal["starter", "regular"] = (
|
||||
"starter" if mapping.type == "starter" else "regular"
|
||||
)
|
||||
if pkg_type == "starter" and has_starter:
|
||||
continue
|
||||
|
||||
mapping = product_mappings.get(pkg.product_code)
|
||||
app_store_product_id = mapping.app_store_product_id if mapping else ""
|
||||
|
||||
packages.append(
|
||||
PackageInfoResult(
|
||||
product_code=pkg.product_code,
|
||||
app_store_product_id=app_store_product_id,
|
||||
type=pkg.type,
|
||||
price=pkg.price,
|
||||
credits=pkg.credits,
|
||||
sort_order=pkg.sort_order,
|
||||
is_starter=pkg.type == PackageType.STARTER,
|
||||
starter_eligible=(
|
||||
pkg.type == PackageType.STARTER and not has_starter
|
||||
),
|
||||
product_code=product_code,
|
||||
app_store_product_id=mapping.app_store_product_id,
|
||||
type=pkg_type,
|
||||
credits=mapping.credits,
|
||||
sort_order=mapping.sort_order,
|
||||
is_starter=pkg_type == "starter",
|
||||
starter_eligible=(pkg_type == "starter" and not has_starter),
|
||||
)
|
||||
)
|
||||
|
||||
return PackagesResult(
|
||||
region=pkg_config.region,
|
||||
currency=pkg_config.currency,
|
||||
packages=sorted(packages, key=lambda p: p.sort_order),
|
||||
)
|
||||
|
||||
async def get_ledger_list(
|
||||
self,
|
||||
*,
|
||||
user_id: UUID,
|
||||
limit: int = 20,
|
||||
cursor: datetime | None = None,
|
||||
) -> tuple[list[LedgerItem], str | None, bool]:
|
||||
rows, has_more = await self._repository.list_ledger(
|
||||
user_id=user_id,
|
||||
limit=limit,
|
||||
cursor=cursor,
|
||||
)
|
||||
|
||||
items: list[LedgerItem] = []
|
||||
for row in rows:
|
||||
items.append(
|
||||
LedgerItem(
|
||||
id=str(row.id),
|
||||
direction=row.direction,
|
||||
amount=row.amount,
|
||||
balance_after=row.balance_after,
|
||||
change_type=row.change_type,
|
||||
created_at=row.created_at.isoformat(),
|
||||
)
|
||||
)
|
||||
|
||||
next_cursor: str | None = None
|
||||
if has_more and items:
|
||||
next_cursor = items[-1].created_at
|
||||
|
||||
return (items, next_cursor, has_more)
|
||||
|
||||
Reference in New Issue
Block a user