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

335 lines
8.8 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`
**新增内容**
```python
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`
**新增方法**
```python
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`
**新增方法**
```python
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 映射**
```python
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`
**新增端点**
```python
@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`
```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`
**新增方法**
```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.dart``account_data_screen.dart`
2. 重命名类:`AccountDeleteScreen``AccountDataScreen`
3. 更新类内状态名:`_AccountDeleteScreenState``_AccountDataScreenState`
**页面结构变更**
```dart
// 之前:只有删除账号入口
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,
),
],
),
],
),
```
**新增方法**
```dart
Future<void> _openPointsLedger() async {
await Navigator.of(context).push<void>(
MaterialPageRoute<void>(
builder: (_) => PointsLedgerScreen(
apiClient: widget.apiClient,
userId: widget.userId,
),
),
);
}
```
**新增 widget 参数**
```dart
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()` 方法,传递新增参数:
```dart
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,
),
),
);
...
}
```
3. 更新菜单项标题(如需要)
### Step 10: 添加 i18n 文案
**文件**
- `apps/lib/l10n/app_zh.arb`
- `apps/lib/l10n/app_en.arb`
- `apps/lib/l10n/app_zh_hant.arb`
**新增文案**
```json
"pointsLedgerTitle": "积分流水",
"pointsLedgerEmpty": "暂无流水记录",
"pointsLedgerLoadingMore": "加载中..."
```
## 验证步骤
### 后端验证
```bash
# 启动后端
./infra/scripts/app.sh start
# 测试 API
curl -H "Authorization: Bearer <token>" \
"http://localhost:8000/api/v1/points/ledger?limit=10"
```
### 前端验证
```bash
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` | 修改 | 新增文案 |