Files
eryao/.trellis/tasks/archive/2026-04/04-28-feat-points-ledger/IMPLEMENTATION_PLAN.md
T

8.8 KiB
Raw Blame History

IMPLEMENTATION_PLAN:积分流水列表功能

概述

本计划按 trellis 工作流制定,遵循 schema → repository → service → router 后端分层和 data → presentation 前端分层原则。

前置条件确认

条件 状态 说明
points_ledger 表存在 结构完整,有索引支持分页
Repository 写入方法存在 append_ledger() 已实现
积分中心页面存在 CoinCenterScreen 可添加入口
前端 points feature 存在 features/points/ 目录已存在

实现步骤

Step 1: 后端 Schema 定义

文件backend/src/v1/points/schemas.py

新增内容

class LedgerItem(BaseModel):
    model_config = ConfigDict(populate_by_name=True, serialize_by_alias=True)

    id: str
    direction: int  # 1=收入, -1=支出
    amount: int = Field(ge=1)
    balance_after: int = Field(alias="balanceAfter", ge=0)
    change_type: str = Field(alias="changeType")
    display_text: str = Field(alias="displayText")
    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")

Step 2: 后端 Repository 方法

文件backend/src/v1/points/repository.py

新增方法

async def list_ledger(
    self,
    *,
    user_id: UUID,
    limit: int,
    cursor: datetime | None = None,
) -> tuple[list[PointsLedger], bool]:
    # 按 created_at DESC 分页查询
    # cursor 为上一页最后一条的 created_at
    # 多查一条判断 has_more

Step 3: 后端 Service 方法

文件backend/src/v1/points/service.py

新增方法

async def get_ledger_list(
    self,
    *,
    user_id: UUID,
    limit: int = 20,
    cursor: str | None = None,
) -> tuple[list[LedgerItem], str | None, bool]:
    # 1. 解析 cursor 为 datetime
    # 2. 调用 repository.list_ledger()
    # 3. 组装 display_text(根据 change_type
    # 4. 返回 (items, next_cursor, has_more)

display_text 映射

CHANGE_TYPE_TEXT = {
    "register": ("注册赠送", "Registration bonus"),
    "purchase": ("购买积分包", "Purchase credits"),
    "consume": ("AI 对话消耗", "AI chat cost"),
    "adjust": ("系统调整", "System adjustment"),
    "refund": ("退款", "Refund"),
}

Step 4: 后端 Router 端点

文件backend/src/v1/points/router.py

新增端点

@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: int = Query(default=20, ge=1, le=100),
    cursor: str | None = Query(default=None),
) -> LedgerListResponse:
    ...

Step 5: 前端 Model 定义

文件apps/lib/features/points/data/models/ledger_item.dart

class LedgerItem {
  const LedgerItem({
    required this.id,
    required this.direction,
    required this.amount,
    required this.balanceAfter,
    required this.changeType,
    required this.displayText,
    required this.createdAt,
  });

  final String id;
  final int direction; // 1=收入, -1=支出
  final int amount;
  final int balanceAfter;
  final String changeType;
  final String displayText;
  final String createdAt;

  factory LedgerItem.fromJson(Map<String, dynamic> json) => ...;
}

class LedgerListResult {
  const LedgerListResult({
    required this.items,
    this.nextCursor,
    required this.hasMore,
  });

  final List<LedgerItem> items;
  final String? nextCursor;
  final bool hasMore;
}

Step 6: 前端 API 方法

文件apps/lib/features/points/data/apis/points_api.dart

新增方法

Future<LedgerListResult> getLedger({
  int limit = 20,
  String? cursor,
}) async {
  final query = {'limit': limit};
  if (cursor != null) query['cursor'] = cursor;
  final response = await _dio.get('/api/v1/points/ledger', queryParameters: query);
  return LedgerListResult.fromJson(response.data);
}

Step 7: 前端流水列表页面

文件apps/lib/features/points/presentation/screens/points_ledger_screen.dart

功能

  • 标题:积分流水
  • 列表项:类型图标 + 文案 + 金额(颜色区分) + 时间
  • 分页加载:滚动到底部加载更多
  • 空状态:暂无流水记录
  • Loading 状态

Step 8: 重命名 AccountDeleteScreen 为 AccountDataScreen

文件操作

  1. 重命名文件:account_delete_screen.dartaccount_data_screen.dart
  2. 重命名类:AccountDeleteScreenAccountDataScreen
  3. 更新类内状态名:_AccountDeleteScreenState_AccountDataScreenState

页面结构变更

// 之前:只有删除账号入口
body: ListView(
  children: [
    SettingsGroupCard(
      children: [
        SettingsMenuTile(icon: Icons.delete_outline_rounded, ...),
      ],
    ),
  ],
),

// 之后:积分流水 + 删除账号
body: ListView(
  children: [
    SettingsGroupCard(
      children: [
        SettingsMenuTile(
          icon: Icons.receipt_long_rounded,
          title: l10n.pointsLedgerTitle,  // 积分流水
          tint: colors.primary,
          background: colors.surfaceContainerHighest,
          onTap: _openPointsLedger,
        ),
        SettingsMenuTile(
          icon: Icons.delete_outline_rounded,
          title: l10n.settingsDeleteAccountTitle,
          tint: colors.error,
          background: colors.surfaceContainerHighest,
          titleColor: colors.error,
          showDivider: false,
          onTap: _confirmDelete,
        ),
      ],
    ),
  ],
),

新增方法

Future<void> _openPointsLedger() async {
  await Navigator.of(context).push<void>(
    MaterialPageRoute<void>(
      builder: (_) => PointsLedgerScreen(
        apiClient: widget.apiClient,
        userId: widget.userId,
      ),
    ),
  );
}

新增 widget 参数

class AccountDataScreen extends StatefulWidget {
  const AccountDataScreen({
    super.key,
    required this.onDeleteAccount,
    required this.apiClient,  // 新增
    required this.userId,     // 新增
  });
  ...
}

Step 9: 更新 SettingsScreen 导入和调用

文件apps/lib/features/settings/presentation/screens/settings_screen.dart

修改

  1. 更新导入:import 'account_data_screen.dart';(替换原 account_delete_screen.dart
  2. 更新 _openAccountDelete() 方法,传递新增参数:
Future<void> _openAccountData() async {
  final deleted = await Navigator.of(context).push<bool>(
    MaterialPageRoute<bool>(
      builder: (_) => AccountDataScreen(
        onDeleteAccount: widget.onDeleteAccount,
        apiClient: widget.apiClient,
        userId: widget.userId,
      ),
    ),
  );
  ...
}
  1. 更新菜单项标题(如需要)

Step 10: 添加 i18n 文案

文件

  • apps/lib/l10n/app_zh.arb
  • apps/lib/l10n/app_en.arb
  • apps/lib/l10n/app_zh_hant.arb

新增文案

"pointsLedgerTitle": "积分流水",
"pointsLedgerEmpty": "暂无流水记录",
"pointsLedgerLoadingMore": "加载中..."

验证步骤

后端验证

# 启动后端
./infra/scripts/app.sh start

# 测试 API
curl -H "Authorization: Bearer <token>" \
  "http://localhost:8000/api/v1/points/ledger?limit=10"

前端验证

cd apps
flutter run
# 导航到设置 -> 积分中心 -> 点击「查看流水」

风险与注意事项

  1. RLS 策略points_ledger 表已有 RLS,确保查询只返回当前用户数据
  2. 游标分页:使用 created_at 作为游标,注意处理相同时间戳的情况
  3. 性能:索引 ix_points_ledger_user_created_at 已存在,查询性能有保障
  4. 空状态:新用户可能无流水,需处理空列表情况

文件变更清单

后端新增/修改

文件 操作 说明
backend/src/v1/points/schemas.py 修改 新增 LedgerItem、LedgerListResponse
backend/src/v1/points/repository.py 修改 新增 list_ledger()
backend/src/v1/points/service.py 修改 新增 get_ledger_list()
backend/src/v1/points/router.py 修改 新增 GET /ledger 端点

前端新增/修改

文件 操作 说明
apps/lib/features/points/data/models/ledger_item.dart 新增 流水项模型
apps/lib/features/points/data/apis/points_api.dart 修改 新增 getLedger()
apps/lib/features/points/presentation/screens/points_ledger_screen.dart 新增 流水列表页面
apps/lib/features/settings/presentation/screens/coin_center_screen.dart 修改 添加入口按钮
apps/lib/l10n/app_*.arb 修改 新增文案