feat(points): 实现积分流水列表功能

- 后端新增 GET /api/v1/points/ledger 接口
- 前端新增积分流水列表页面
- 积分中心添加「查看流水」入口
- 重命名 AccountDeleteScreen 为 AccountDataScreen
- 流水列表支持分页加载和空状态展示
This commit is contained in:
ZL-Q
2026-04-28 17:19:08 +08:00
parent a83001de0d
commit 940c67e642
12 changed files with 794 additions and 70 deletions
+17
View File
@@ -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)
+55 -2
View File
@@ -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,
)
+19 -3
View File
@@ -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")
+48 -32
View File
@@ -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)