chore(task): archive 04-28-feat-points-ledger
This commit is contained in:
@@ -5,7 +5,7 @@
|
||||
### 已完成
|
||||
- [x] 协议文档更新(`user-points-chat-data-protocol.md`、`http-error-codes.md`)
|
||||
- [x] PRD 退款扣回策略已明确
|
||||
- [x] 套餐配置 YAML 已正确命名(`new_user_pack`, `basic_pack` 等)
|
||||
- [x] 套餐配置 YAML 已正确命名(`new_user_pack`, `starter_pack` 等)
|
||||
- [x] **Phase 1: 数据库与枚举**(2026-04-27 完成)
|
||||
- [x] **Phase 2: 后端支付服务**(2026-04-27 完成)
|
||||
- [x] **Phase 3: iOS / Flutter IAP 接入**(2026-04-27 完成)
|
||||
@@ -178,8 +178,8 @@ product_mappings:
|
||||
app_store_product_id: com.meeyao.qianwen.new_user_pack
|
||||
credits: 60
|
||||
type: starter
|
||||
basic_pack:
|
||||
app_store_product_id: com.meeyao.qianwen.basic_pack
|
||||
starter_pack:
|
||||
app_store_product_id: com.meeyao.qianwen.starter_pack
|
||||
credits: 100
|
||||
type: regular
|
||||
popular_pack:
|
||||
@@ -286,7 +286,7 @@ apps/lib/features/payments/
|
||||
- [x] 创建 4 个消耗型 IAP 商品(Product ID 已确认与映射表一致)
|
||||
- [x] Product ID 与映射表一致
|
||||
- `com.meeyao.qianwen.new_user_pack` — 新手包
|
||||
- `com.meeyao.qianwen.basic_pack` — 基础包
|
||||
- `com.meeyao.qianwen.starter_pack` — 入门包
|
||||
- `com.meeyao.qianwen.popular_pack` — 热门包
|
||||
- `com.meeyao.qianwen.premium_pack` — 高级包
|
||||
- [ ] 配置价格和描述
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
- 成本/审计流水:`points_audit_ledger`
|
||||
- 注册奖励与新手包资格表:`register_bonus_claims`
|
||||
- 积分套餐配置:`backend/src/core/config/static/packages/*.yaml`
|
||||
- 当前套餐:`new_user_pack`、`basic_pack`、`popular_pack`、`premium_pack`
|
||||
- 当前套餐:`new_user_pack`、`starter_pack`、`popular_pack`、`premium_pack`
|
||||
- 当前套餐接口:`GET /api/v1/points/packages`
|
||||
- 当前积分余额接口:`GET /api/v1/points/balance`
|
||||
|
||||
@@ -62,7 +62,7 @@
|
||||
| 后端 `product_code` | App Store Product ID | 类型 | 积分 | 备注 |
|
||||
|---|---|---|---:|---|
|
||||
| `new_user_pack` | `com.meeyao.qianwen.new_user_pack` | starter | 60 | 每个邮箱身份只允许购买一次 |
|
||||
| `basic_pack` | `com.meeyao.qianwen.basic_pack` | regular | 100 | 可重复购买 |
|
||||
| `starter_pack` | `com.meeyao.qianwen.starter_pack` | regular | 100 | 可重复购买 |
|
||||
| `popular_pack` | `com.meeyao.qianwen.popular_pack` | regular | 210 | 可重复购买 |
|
||||
| `premium_pack` | `com.meeyao.qianwen.premium_pack` | regular | 415 | 可重复购买 |
|
||||
|
||||
@@ -109,7 +109,7 @@
|
||||
|---|---|---|
|
||||
| `id` | UUID PK | 内部交易记录 ID |
|
||||
| `user_id` | UUID not null | 当前购买归属用户,来自后端 JWT,不接受客户端传入 |
|
||||
| `product_code` | varchar not null | 后端套餐码,例如 `basic_pack` |
|
||||
| `product_code` | varchar not null | 后端套餐码,例如 `starter_pack` |
|
||||
| `app_store_product_id` | varchar not null | Apple 商品 ID |
|
||||
| `transaction_id` | varchar not null unique | Apple 交易 ID,核心幂等键 |
|
||||
| `original_transaction_id` | varchar null | Apple 原始交易 ID |
|
||||
@@ -165,8 +165,8 @@
|
||||
{
|
||||
"source": "apple_iap",
|
||||
"platform": "ios",
|
||||
"product_code": "basic_pack",
|
||||
"app_store_product_id": "com.meeyao.qianwen.basic_pack",
|
||||
"product_code": "starter_pack",
|
||||
"app_store_product_id": "com.meeyao.qianwen.starter_pack",
|
||||
"transaction_id": "1000000123456789",
|
||||
"original_transaction_id": "1000000123456789",
|
||||
"environment": "Production",
|
||||
@@ -209,8 +209,8 @@
|
||||
|
||||
```json
|
||||
{
|
||||
"productCode": "basic_pack",
|
||||
"appStoreProductId": "com.meeyao.qianwen.basic_pack",
|
||||
"productCode": "starter_pack",
|
||||
"appStoreProductId": "com.meeyao.qianwen.starter_pack",
|
||||
"transactionId": "1000000123456789",
|
||||
"signedTransactionInfo": "eyJhbGciOiJFUzI1NiIs...",
|
||||
"appAccountToken": "7c4c7a82-2f6f-4e70-b57a-8b0a7f2e9b72"
|
||||
@@ -230,7 +230,7 @@
|
||||
```json
|
||||
{
|
||||
"status": "granted",
|
||||
"productCode": "basic_pack",
|
||||
"productCode": "starter_pack",
|
||||
"transactionId": "1000000123456789",
|
||||
"creditsAdded": 100,
|
||||
"newBalance": 180,
|
||||
@@ -243,7 +243,7 @@
|
||||
```json
|
||||
{
|
||||
"status": "already_granted",
|
||||
"productCode": "basic_pack",
|
||||
"productCode": "starter_pack",
|
||||
"transactionId": "1000000123456789",
|
||||
"creditsAdded": 0,
|
||||
"newBalance": 180,
|
||||
|
||||
@@ -0,0 +1,404 @@
|
||||
# Implementation Plan: App启动时语言和时区自动设置
|
||||
|
||||
## Phase 1: 前端 - 读取系统语言/时区
|
||||
|
||||
### 1.1 创建系统Locale工具函数
|
||||
|
||||
**文件**: `apps/lib/app/locale_utils.dart` (新建)
|
||||
|
||||
**Locale映射规则** (基于项目现有约定):
|
||||
|
||||
| 系统Locale | Flutter Locale | 存储Tag |
|
||||
|-----------|----------------|---------|
|
||||
| en, en-US, en-GB, ... | `Locale('en')` | `en-US` |
|
||||
| zh, zh-CN, zh-SG, zh-Hans-* | `Locale('zh')` | `zh-CN` |
|
||||
| zh-Hant, zh-TW, zh-HK, zh-MO | `Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hant')` | `zh-Hant` |
|
||||
| 其他 | null (回退默认) | `zh-CN` |
|
||||
|
||||
```dart
|
||||
import 'dart:ui';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 从系统Locale映射到App支持的Locale
|
||||
/// 返回 null 表示不支持,调用方应使用默认值
|
||||
Locale? resolveSystemLocale(Locale systemLocale) {
|
||||
final lang = systemLocale.languageCode.toLowerCase();
|
||||
final script = systemLocale.scriptCode;
|
||||
final country = systemLocale.countryCode;
|
||||
|
||||
// 英文: en, en-US, en-GB, ... → Locale('en') → 存储 en-US
|
||||
if (lang == 'en') {
|
||||
return const Locale('en');
|
||||
}
|
||||
|
||||
// 中文处理
|
||||
if (lang == 'zh') {
|
||||
// 繁体: zh-Hant, zh-TW, zh-HK, zh-MO → Locale(zh, Hant) → 存储 zh-Hant
|
||||
if (script == 'Hant' || country == 'TW' || country == 'HK' || country == 'MO') {
|
||||
return const Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hant');
|
||||
}
|
||||
// 简体: zh, zh-CN, zh-SG, zh-Hans-* → Locale('zh') → 存储 zh-CN
|
||||
return const Locale('zh');
|
||||
}
|
||||
|
||||
// 其他语言不支持,回退到默认
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 获取系统首选Locale
|
||||
Locale getSystemLocale() {
|
||||
return PlatformDispatcher.instance.locale;
|
||||
}
|
||||
```
|
||||
|
||||
### 1.2 创建系统时区工具函数
|
||||
|
||||
**文件**: `apps/lib/app/timezone_utils.dart` (新建)
|
||||
|
||||
**方案选择**:
|
||||
- 方案1: 使用 `flutter_timezone` 包 (推荐,直接返回IANA ID)
|
||||
- 方案2: 使用 `DateTime.now().timeZoneName` + 映射表 (无额外依赖,但需维护映射)
|
||||
|
||||
```dart
|
||||
import 'package:flutter_timezone/flutter_timezone.dart';
|
||||
|
||||
/// 获取系统时区IANA ID
|
||||
/// 例如: Asia/Shanghai, America/New_York, Europe/London
|
||||
Future<String> getSystemTimezone() async {
|
||||
try {
|
||||
final timezone = await FlutterTimezone.getLocalTimezone();
|
||||
// 验证是否为有效IANA ID
|
||||
if (timezone.isNotEmpty) {
|
||||
return timezone;
|
||||
}
|
||||
} catch (_) {
|
||||
// ignore
|
||||
}
|
||||
// 回退到默认
|
||||
return 'Asia/Shanghai';
|
||||
}
|
||||
```
|
||||
|
||||
**依赖添加** (`apps/pubspec.yaml`):
|
||||
```yaml
|
||||
dependencies:
|
||||
flutter_timezone: ^3.0.1
|
||||
```
|
||||
|
||||
### 1.3 扩展SessionStore
|
||||
|
||||
**文件**: `apps/lib/core/auth/session_store.dart`
|
||||
|
||||
```dart
|
||||
// 新增
|
||||
static const String _timezoneKey = 'selected_timezone';
|
||||
|
||||
Future<void> saveTimezone(String timezone) async {
|
||||
await _kvStore.setString(_timezoneKey, timezone);
|
||||
}
|
||||
|
||||
Future<String?> getTimezone() async {
|
||||
return _kvStore.getString(_timezoneKey);
|
||||
}
|
||||
```
|
||||
|
||||
### 1.4 修改App启动流程
|
||||
|
||||
**文件**: `apps/lib/app/app.dart`
|
||||
|
||||
**步骤1**: 添加 `_timezone` 状态变量声明
|
||||
|
||||
```dart
|
||||
// 在 State 类顶部添加
|
||||
String _timezone = 'Asia/Shanghai';
|
||||
```
|
||||
|
||||
**步骤2**: 修改 `_bootstrap()` 方法
|
||||
```dart
|
||||
Future<void> _bootstrap() async {
|
||||
// 1. 语言处理
|
||||
final savedLocaleTag = await _sessionStore.getLocaleTag();
|
||||
final Locale locale;
|
||||
if (savedLocaleTag != null) {
|
||||
locale = localeFromLanguageTag(savedLocaleTag);
|
||||
} else {
|
||||
final systemLocale = getSystemLocale();
|
||||
locale = resolveSystemLocale(systemLocale) ?? const Locale('zh');
|
||||
await _sessionStore.saveLocaleTag(languageTagFromLocale(locale));
|
||||
}
|
||||
|
||||
// 2. 时区处理
|
||||
final savedTimezone = await _sessionStore.getTimezone();
|
||||
final String timezone;
|
||||
if (savedTimezone != null) {
|
||||
timezone = savedTimezone;
|
||||
} else {
|
||||
timezone = await getSystemTimezone();
|
||||
await _sessionStore.saveTimezone(timezone);
|
||||
}
|
||||
|
||||
// 3. 设置状态
|
||||
setState(() {
|
||||
_locale = locale;
|
||||
_timezone = timezone;
|
||||
});
|
||||
|
||||
// 4. 启动认证
|
||||
await _authBloc.start();
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: 前端 - 注册时传递语言/时区
|
||||
|
||||
### 2.1 扩展AuthApi
|
||||
|
||||
**文件**: `apps/lib/features/auth/data/apis/auth_api.dart`
|
||||
|
||||
```dart
|
||||
Future<SessionResponse> createEmailSession({
|
||||
required String email,
|
||||
required String token,
|
||||
String? language,
|
||||
String? timezone,
|
||||
}) async {
|
||||
final data = <String, dynamic>{
|
||||
'email': email,
|
||||
'token': token,
|
||||
};
|
||||
if (language != null) data['language'] = language;
|
||||
if (timezone != null) data['timezone'] = timezone;
|
||||
|
||||
final json = await _apiClient.postJson('/api/v1/auth/email-session', data: data);
|
||||
return SessionResponse.fromJson(json);
|
||||
}
|
||||
```
|
||||
|
||||
### 2.2 扩展AuthRepository
|
||||
|
||||
**文件**: `apps/lib/features/auth/data/repositories/auth_repository.dart`
|
||||
|
||||
```dart
|
||||
// 接口定义
|
||||
abstract class AuthRepository {
|
||||
// ... 现有方法 ...
|
||||
Future<User> loginWithEmailOtp({
|
||||
required String email,
|
||||
required String otp,
|
||||
String? language,
|
||||
String? timezone,
|
||||
});
|
||||
}
|
||||
|
||||
// 实现
|
||||
class AuthRepositoryImpl implements AuthRepository {
|
||||
// ... 现有代码 ...
|
||||
|
||||
@override
|
||||
Future<User> loginWithEmailOtp({
|
||||
required String email,
|
||||
required String otp,
|
||||
String? language,
|
||||
String? timezone,
|
||||
}) async {
|
||||
final response = await _authApi.createEmailSession(
|
||||
email: email,
|
||||
token: otp,
|
||||
language: language,
|
||||
timezone: timezone,
|
||||
);
|
||||
// ... 现有的 session 处理逻辑 ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2.3 扩展AuthBloc
|
||||
|
||||
**文件**: `apps/lib/features/auth/presentation/bloc/auth_bloc.dart`
|
||||
|
||||
```dart
|
||||
Future<void> loginWithOtp({
|
||||
required String email,
|
||||
required String otp,
|
||||
String? language,
|
||||
String? timezone,
|
||||
}) async {
|
||||
final user = await _repository.loginWithEmailOtp(
|
||||
email: email,
|
||||
otp: otp,
|
||||
language: language,
|
||||
timezone: timezone,
|
||||
);
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### 2.4 修改App调用点
|
||||
|
||||
**文件**: `apps/lib/app/app.dart`
|
||||
|
||||
```dart
|
||||
// LoginScreen 的 onLoginWithOtp 回调
|
||||
onLoginWithOtp: (email, otp) {
|
||||
return _authBloc.loginWithOtp(
|
||||
email: email,
|
||||
otp: otp,
|
||||
language: languageTagFromLocale(_locale),
|
||||
timezone: _timezone,
|
||||
);
|
||||
},
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: 后端 - 接收语言/时区
|
||||
|
||||
### 3.1 扩展Schema
|
||||
|
||||
**文件**: `backend/src/v1/auth/schemas.py`
|
||||
|
||||
```python
|
||||
class EmailSessionCreateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
email: str = Field(pattern=SUPABASE_EMAIL_PATTERN)
|
||||
token: str = Field(min_length=6, max_length=6)
|
||||
language: str | None = Field(default=None, max_length=20)
|
||||
timezone: str | None = Field(default=None, max_length=50)
|
||||
```
|
||||
|
||||
### 3.2 扩展AuthService
|
||||
|
||||
**文件**: `backend/src/v1/auth/service.py`
|
||||
|
||||
```python
|
||||
async def create_email_session(
|
||||
self,
|
||||
payload: EmailSessionCreateRequest,
|
||||
) -> SessionResponse:
|
||||
# ... 现有的 session 创建逻辑 ...
|
||||
|
||||
# 如果提供了语言/时区,更新Profile
|
||||
if payload.language or payload.timezone:
|
||||
await self._update_profile_preferences(
|
||||
user_id=user.id,
|
||||
language=payload.language,
|
||||
timezone=payload.timezone,
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
async def _update_profile_preferences(
|
||||
self,
|
||||
user_id: UUID,
|
||||
language: str | None,
|
||||
timezone: str | None,
|
||||
) -> None:
|
||||
"""更新用户Profile的语言/时区偏好设置"""
|
||||
profile = await self.profile_repository.get_profile_by_user_id(user_id)
|
||||
if profile is None:
|
||||
return
|
||||
|
||||
settings = profile.settings or {}
|
||||
preferences = settings.get("preferences", {})
|
||||
|
||||
if language is not None:
|
||||
preferences["language"] = language
|
||||
if timezone is not None:
|
||||
preferences["timezone"] = timezone
|
||||
|
||||
settings["preferences"] = preferences
|
||||
profile.settings = settings
|
||||
|
||||
await self.profile_repository.save()
|
||||
```
|
||||
|
||||
### 3.3 AuthService依赖注入
|
||||
|
||||
**文件**: `backend/src/v1/auth/service.py`
|
||||
|
||||
AuthService 需要注入 `ProfileRepository` 或 `UserRepository` 以便更新 Profile。
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class AuthService:
|
||||
# ... 现有依赖 ...
|
||||
profile_repository: SQLAlchemyUserRepository # 新增
|
||||
```
|
||||
|
||||
**或**: 复用现有的 `v1/users/service.py` 中的 `UserService.update_settings()` 方法。
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: 前端 - 登录后同步时区
|
||||
|
||||
### 4.1 修改_refreshProfile
|
||||
|
||||
**文件**: `apps/lib/app/app.dart`
|
||||
|
||||
```dart
|
||||
Future<void> _refreshProfile({required String userEmail}) async {
|
||||
// ... 现有逻辑 ...
|
||||
|
||||
final serverLanguage = profile.preferences.language;
|
||||
final serverTimezone = profile.preferences.timezone;
|
||||
|
||||
// 同步语言
|
||||
await _sessionStore.saveLocaleTag(serverLanguage);
|
||||
|
||||
// 同步时区
|
||||
await _sessionStore.saveTimezone(serverTimezone);
|
||||
|
||||
setState(() {
|
||||
_locale = localeFromLanguageTag(serverLanguage);
|
||||
_timezone = serverTimezone;
|
||||
_profileSettings = profile;
|
||||
// ...
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: 验证和测试
|
||||
|
||||
### 5.1 单元测试
|
||||
|
||||
- [ ] `resolveSystemLocale` 映射正确性
|
||||
- [ ] `getSystemTimezone` 返回有效IANA ID
|
||||
- [ ] `SessionStore` 时区存取
|
||||
|
||||
### 5.2 集成测试
|
||||
|
||||
- [ ] 新设备首次打开 → 系统语言/时区生效
|
||||
- [ ] 新用户注册 → 后端Profile正确
|
||||
- [ ] 已有用户登录 → 服务器值同步
|
||||
- [ ] 有本地存储 → 使用本地值
|
||||
|
||||
### 5.3 手动测试
|
||||
|
||||
- [ ] iOS系统语言设为英文 → App显示英文
|
||||
- [ ] iOS系统语言设为繁体中文 → App显示繁体
|
||||
- [ ] iOS系统时区设为非上海 → App时区正确
|
||||
|
||||
---
|
||||
|
||||
## 实施顺序
|
||||
|
||||
1. **Phase 1.3**: 扩展SessionStore(最小改动,无依赖)
|
||||
2. **Phase 1.1-1.2**: 创建工具函数
|
||||
3. **Phase 1.4**: 修改启动流程
|
||||
4. **Phase 3.1**: 后端Schema扩展
|
||||
5. **Phase 3.2-3.3**: 后端Service逻辑
|
||||
6. **Phase 2**: 前端注册流程扩展
|
||||
7. **Phase 4**: 登录后同步时区
|
||||
8. **Phase 5**: 测试验证
|
||||
|
||||
---
|
||||
|
||||
## 依赖
|
||||
|
||||
- 可选: `flutter_timezone` 包(如需精确时区ID)
|
||||
- 添加到 `apps/pubspec.yaml`
|
||||
- 或使用 `DateTime.now().timeZoneName` + 映射表(无依赖)
|
||||
@@ -0,0 +1,106 @@
|
||||
# PRD: App启动时语言和时区自动设置
|
||||
|
||||
## 背景
|
||||
|
||||
当前App启动时语言硬编码为 `zh`,不读取iOS系统语言;时区完全不处理。需要实现三种场景的自动设置逻辑。
|
||||
|
||||
## 需求
|
||||
|
||||
### 场景1:新用户首次打开App → 注册时写入后端
|
||||
|
||||
1. App无本地存储信息(首次打开)
|
||||
2. 读取iOS系统语言和时区
|
||||
3. 自动设置App界面语言和时区
|
||||
4. 用户注册时,将语言和时区作为请求参数传给后端
|
||||
5. 后端创建用户Profile时使用传入值
|
||||
|
||||
### 场景2:已有用户在新设备首次登录 → 从服务器同步
|
||||
|
||||
1. App无本地存储信息(新设备首次打开)
|
||||
2. 读取iOS系统语言和时区作为临时值
|
||||
3. 自动设置App界面语言和时区(临时)
|
||||
4. 用户登录成功后,从服务器拉取Profile中的语言和时区
|
||||
5. 用服务器值更新本地存储和App设置
|
||||
|
||||
### 场景3:有本地存储 → 使用本地值
|
||||
|
||||
1. App有本地存储的语言和时区信息
|
||||
2. 直接使用本地值,不读取系统语言和时区
|
||||
3. 登录后仍从服务器同步(确保一致性)
|
||||
|
||||
## 技术方案
|
||||
|
||||
### 前端
|
||||
|
||||
1. **读取系统语言**
|
||||
- 使用 `PlatformDispatcher.instance.locale` 获取系统首选语言
|
||||
- 通过 `resolveSystemLocale()` 映射到 App 支持的 Locale
|
||||
|
||||
2. **读取系统时区**
|
||||
- 使用 `flutter_timezone` 包的 `getLocalTimezone()` 获取 IANA 时区 ID
|
||||
- 直接使用返回值,后端会验证有效性
|
||||
|
||||
3. **本地存储扩展**
|
||||
- `SessionStore` 新增 `saveTimezone()` / `getTimezone()`
|
||||
- 现有 `saveLocaleTag()` / `getLocaleTag()` 已支持语言
|
||||
|
||||
4. **启动流程修改** (`app.dart:_bootstrap`)
|
||||
```
|
||||
if (本地有locale) {
|
||||
使用本地locale
|
||||
} else {
|
||||
读取系统locale → 保存到本地
|
||||
}
|
||||
|
||||
if (本地有时区) {
|
||||
使用本地时区
|
||||
} else {
|
||||
读取系统时区 → 保存到本地
|
||||
}
|
||||
```
|
||||
|
||||
5. **注册请求扩展**
|
||||
- `AuthApi.createEmailSession` 新增 `language` 和 `timezone` 参数
|
||||
- `EmailSessionCreateRequest` schema 扩展
|
||||
|
||||
6. **登录后同步**
|
||||
- 现有 `_refreshProfile()` 已同步语言
|
||||
- 扩展同步时区
|
||||
|
||||
### 后端
|
||||
|
||||
1. **Schema扩展** (`v1/auth/schemas.py`)
|
||||
```python
|
||||
class EmailSessionCreateRequest(BaseModel):
|
||||
email: str
|
||||
token: str
|
||||
language: str | None = None # BCP-47 tag
|
||||
timezone: str | None = None # IANA timezone
|
||||
```
|
||||
|
||||
2. **Service逻辑** (`v1/auth/service.py`)
|
||||
- `create_email_session` 接收语言/时区
|
||||
- 创建/更新Profile时使用传入值(如果提供)
|
||||
|
||||
3. **Profile默认值保持不变**
|
||||
- `language: str = "zh-CN"`
|
||||
- `timezone: str = "Asia/Shanghai"`
|
||||
|
||||
## 验收标准
|
||||
|
||||
1. 新设备首次打开App,界面语言与iOS系统语言一致
|
||||
2. 新用户注册后,后端Profile的语言/时区与App一致
|
||||
3. 已有用户登录后,App语言/时区与服务器Profile一致
|
||||
4. 有本地存储时,App启动不请求系统语言/时区
|
||||
5. 支持的语言映射:
|
||||
- iOS `en` / `en-US` / `en-GB` → Flutter `Locale('en')` → 存储 `en-US`
|
||||
- iOS `zh` / `zh-CN` / `zh-SG` / `zh-Hans-*` → Flutter `Locale('zh')` → 存储 `zh-CN`
|
||||
- iOS `zh-Hant` / `zh-TW` / `zh-HK` / `zh-MO` → Flutter `Locale(zh, Hant)` → 存储 `zh-Hant`
|
||||
6. 时区为有效IANA ID (如 `Asia/Shanghai`, `America/New_York`)
|
||||
|
||||
## 风险
|
||||
|
||||
1. iOS系统语言可能不在支持列表中 → 回退到 `zh-CN`
|
||||
2. `flutter_timezone` 包可能返回无效时区 → 后端验证 + 前端回退默认值
|
||||
3. 用户修改系统语言/时区后,App不会自动更新(符合场景3设计)
|
||||
4. 新用户注册时网络请求失败 → Profile 使用后端默认值,用户可在设置中修改
|
||||
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"title": "App启动时语言和时区自动设置",
|
||||
"slug": "04-28-feat-locale-timezone-bootstrap",
|
||||
"status": "pending",
|
||||
"created_at": "2026-04-28",
|
||||
"phases": [
|
||||
{
|
||||
"name": "Phase 1: 前端读取系统语言/时区",
|
||||
"status": "pending",
|
||||
"items": [
|
||||
"1.1 创建系统Locale工具函数",
|
||||
"1.2 创建系统时区工具函数",
|
||||
"1.3 扩展SessionStore存储时区",
|
||||
"1.4 修改App启动流程_bootstrap"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Phase 2: 前端注册时传递语言/时区",
|
||||
"status": "pending",
|
||||
"items": [
|
||||
"2.1 扩展AuthApi.createEmailSession",
|
||||
"2.2 扩展AuthRepository",
|
||||
"2.3 扩展AuthBloc.loginWithOtp",
|
||||
"2.4 修改App调用点传递语言/时区"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Phase 3: 后端接收语言/时区",
|
||||
"status": "pending",
|
||||
"items": [
|
||||
"3.1 扩展EmailSessionCreateRequest Schema",
|
||||
"3.2 扩展AuthService.create_email_session",
|
||||
"3.3 新增Profile更新方法"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Phase 4: 前端登录后同步时区",
|
||||
"status": "pending",
|
||||
"items": [
|
||||
"4.1 修改_refreshProfile同步时区"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Phase 5: 测试验证",
|
||||
"status": "pending",
|
||||
"items": [
|
||||
"5.1 单元测试",
|
||||
"5.2 集成测试",
|
||||
"5.3 手动测试"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
# IMPLEMENTATION_PLAN:统一语言设置
|
||||
|
||||
## 前置条件
|
||||
|
||||
| 条件 | 状态 |
|
||||
|------|------|
|
||||
| 后端 Schema 定义清晰 | ✅ |
|
||||
| 前端 Model 定义清晰 | ✅ |
|
||||
| AI 运行时使用 `ai_language` | ✅ |
|
||||
| 协议文档存在 | ✅ |
|
||||
|
||||
## 实现步骤
|
||||
|
||||
### Step 1: 更新协议文档
|
||||
|
||||
**文件**: `docs/protocols/profile/profile-protocol.md`
|
||||
|
||||
- 将 `interface_language` 和 `ai_language` 合并为 `language`
|
||||
- 更新示例 JSON
|
||||
|
||||
---
|
||||
|
||||
### Step 2: 后端 Schema
|
||||
|
||||
**文件**: `backend/src/schemas/shared/user.py`
|
||||
|
||||
```python
|
||||
class PreferenceSettings(BaseModel):
|
||||
language: str = "zh-CN"
|
||||
timezone: str = "Asia/Shanghai"
|
||||
country: str = "US"
|
||||
|
||||
@field_validator("language")
|
||||
@classmethod
|
||||
def validate_language(cls, value: str) -> str:
|
||||
if not _BCP47_PATTERN.fullmatch(value):
|
||||
raise ValueError("language must be a valid BCP-47 tag")
|
||||
return value
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Step 3: 后端 AI 运行时
|
||||
|
||||
**文件**: `backend/src/core/agentscope/runtime/runner.py`
|
||||
|
||||
```python
|
||||
# 第 268-276 行
|
||||
language = "zh-CN"
|
||||
if user_context.settings is not None:
|
||||
prefs = getattr(user_context.settings, "preferences", None)
|
||||
if prefs is not None:
|
||||
language = getattr(prefs, "language", "zh-CN") or "zh-CN"
|
||||
|
||||
system_prompt = build_system_prompt(
|
||||
agent_type=stage_config.agent_type,
|
||||
language=language,
|
||||
llm_config=stage_config.llm_config,
|
||||
tools=None,
|
||||
now_utc=datetime.now(timezone.utc),
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Step 4: 后端 Prompt 构建
|
||||
|
||||
**文件**: `backend/src/core/agentscope/prompts/system_prompt.py`
|
||||
|
||||
```python
|
||||
def _build_output_rules(*, language: str) -> str:
|
||||
lang_label = _get_language_label(language)
|
||||
...
|
||||
|
||||
def build_system_prompt(
|
||||
*,
|
||||
agent_type: AgentType,
|
||||
language: str,
|
||||
llm_config: LlmConfig,
|
||||
tools: list[ToolSchema] | None,
|
||||
now_utc: datetime,
|
||||
) -> str:
|
||||
...
|
||||
_build_output_rules(language=language),
|
||||
```
|
||||
|
||||
**文件**: `backend/src/core/agentscope/prompts/worker_rules.py`
|
||||
|
||||
```python
|
||||
def get_worker_role_playing(language: str) -> str:
|
||||
_ = language
|
||||
...
|
||||
|
||||
def get_worker_output_rules(language: str) -> str:
|
||||
if language.startswith("en"):
|
||||
...
|
||||
```
|
||||
|
||||
**文件**: `backend/src/core/agentscope/prompts/agent_prompt.py`
|
||||
|
||||
```python
|
||||
def build_agent_prompt(
|
||||
*,
|
||||
language: str = "zh-CN",
|
||||
) -> str:
|
||||
role_playing = get_worker_role_playing(language)
|
||||
output_rules = get_worker_output_rules(language)
|
||||
...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Step 5: 后端测试
|
||||
|
||||
**文件**: `backend/tests/unit/test_parse_profile_settings.py`
|
||||
|
||||
- 字段名 `ai_language` → `language`
|
||||
- 字段名 `interface_language` → `language`
|
||||
|
||||
**文件**: `backend/tests/unit/test_agentscope_prompts.py`
|
||||
|
||||
- 参数名 `ai_language` → `language`
|
||||
|
||||
---
|
||||
|
||||
### Step 6: 数据库数据更新
|
||||
|
||||
直接用 Supabase MCP 执行 SQL(无需迁移脚本):
|
||||
|
||||
```sql
|
||||
UPDATE profiles
|
||||
SET settings = jsonb_set(
|
||||
settings - 'interface_language' - 'ai_language',
|
||||
'{preferences,language}',
|
||||
COALESCE(
|
||||
settings->'preferences'->>'interface_language',
|
||||
settings->'preferences'->>'ai_language',
|
||||
'"zh-CN"'
|
||||
)::jsonb
|
||||
)
|
||||
WHERE settings->'preferences' ?| array['interface_language', 'ai_language'];
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Step 7: 前端 Model
|
||||
|
||||
**文件**: `apps/lib/features/settings/data/models/profile_settings.dart`
|
||||
|
||||
```dart
|
||||
class PreferenceSettings {
|
||||
const PreferenceSettings({
|
||||
this.language = 'zh-CN',
|
||||
this.timezone = 'Asia/Shanghai',
|
||||
this.country = 'US',
|
||||
});
|
||||
|
||||
final String language;
|
||||
final String timezone;
|
||||
final String country;
|
||||
|
||||
PreferenceSettings copyWith({
|
||||
String? language,
|
||||
String? timezone,
|
||||
String? country,
|
||||
}) {
|
||||
return PreferenceSettings(
|
||||
language: language ?? this.language,
|
||||
timezone: timezone ?? this.timezone,
|
||||
country: country ?? this.country,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 更新 defaultsForLocale
|
||||
factory ProfileSettingsV1.defaultsForLocale(Locale locale) {
|
||||
final tag = languageTagFromLocale(locale);
|
||||
return ProfileSettingsV1(
|
||||
preferences: PreferenceSettings(language: tag),
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Step 8: 前端 API
|
||||
|
||||
**文件**: `apps/lib/features/settings/data/apis/profile_api.dart`
|
||||
|
||||
```dart
|
||||
// 序列化 (第 45 行)
|
||||
'language': settings.preferences.language,
|
||||
|
||||
// 反序列化 (第 114 行)
|
||||
language: (preferencesRaw['language'] as String?) ?? 'zh-CN',
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Step 9: 前端设置界面
|
||||
|
||||
**文件**: `apps/lib/features/settings/presentation/screens/general_settings_screen.dart`
|
||||
|
||||
移除第 75-95 行的 AI 语言 `SettingsMenuTile`,修改剩余的语言选项:
|
||||
|
||||
```dart
|
||||
SettingsMenuTile(
|
||||
icon: Icons.language_rounded,
|
||||
title: l10n.settingsLanguage,
|
||||
subtitle: displayLanguageLabel(
|
||||
l10n,
|
||||
_settings.preferences.language,
|
||||
),
|
||||
tint: colors.primary,
|
||||
background: colors.surfaceContainerHighest,
|
||||
showDivider: false,
|
||||
onTap: () => _selectLanguage(
|
||||
_settings.preferences.language,
|
||||
(lang) => setState(() {
|
||||
_settings = _settings.copyWith(
|
||||
preferences: _settings.preferences.copyWith(
|
||||
language: lang,
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Step 10: 前端 i18n
|
||||
|
||||
**文件**: `apps/lib/l10n/app_zh.arb`
|
||||
|
||||
```diff
|
||||
- "settingsInterfaceLanguage": "界面语言",
|
||||
- "settingsAiLanguage": "AI回复语言",
|
||||
- "settingsAiLanguageHint": "该字段将对齐..."
|
||||
+ "settingsLanguage": "语言",
|
||||
```
|
||||
|
||||
**文件**: `apps/lib/l10n/app_en.arb`
|
||||
|
||||
```diff
|
||||
- "settingsInterfaceLanguage": "Interface Language",
|
||||
- "settingsAiLanguage": "AI Response Language",
|
||||
- "settingsAiLanguageHint": "This field will align..."
|
||||
+ "settingsLanguage": "Language",
|
||||
```
|
||||
|
||||
**文件**: `apps/lib/l10n/app_zh_hant.arb`
|
||||
|
||||
```diff
|
||||
- "settingsInterfaceLanguage": "介面語言",
|
||||
- "settingsAiLanguage": "AI 回覆語言",
|
||||
+ "settingsLanguage": "語言",
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Step 11: 重新生成 l10n
|
||||
|
||||
```bash
|
||||
cd apps && flutter gen-l10n
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Step 12: 更新其他协议文档
|
||||
|
||||
**文件**: `docs/protocols/divination/divination-run-protocol.md`
|
||||
|
||||
第 240 行:
|
||||
```diff
|
||||
- - Language rule: `conclusion`, `focus_points`, `advice`, `keywords`, `answer` should follow user `ai_language` preference unless user explicitly requests otherwise.
|
||||
+ - Language rule: `conclusion`, `focus_points`, `advice`, `keywords`, `answer` should follow user `language` preference unless user explicitly requests otherwise.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
# 后端
|
||||
cd backend
|
||||
uv run pytest tests/unit/test_parse_profile_settings.py tests/unit/test_agentscope_prompts.py -v
|
||||
./infra/scripts/dev-migrate.sh migrate
|
||||
|
||||
# 前端
|
||||
cd apps
|
||||
flutter gen-l10n
|
||||
flutter analyze
|
||||
```
|
||||
@@ -0,0 +1,93 @@
|
||||
# PRD:统一语言设置
|
||||
|
||||
## 1. 背景
|
||||
|
||||
当前存在两个独立语言设置:`interface_language`(界面语言)和 `ai_language`(AI 回复语言),用户需分别设置。本任务将其统一为单一 `language` 字段。
|
||||
|
||||
## 2. 目标
|
||||
|
||||
1. 合并 `interface_language` + `ai_language` → `language`
|
||||
2. 后端 AI 统一使用 `language` 作为回复语言
|
||||
3. 前端移除 AI 语言 UI,设置界面只保留一个"语言"选项
|
||||
4. 文案从"界面语言"改为"语言"
|
||||
|
||||
## 3. 变更内容
|
||||
|
||||
### 3.1 Schema 变更
|
||||
|
||||
**变更前**:
|
||||
```json
|
||||
{
|
||||
"preferences": {
|
||||
"interface_language": "zh-CN",
|
||||
"ai_language": "zh-CN"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**变更后**:
|
||||
```json
|
||||
{
|
||||
"preferences": {
|
||||
"language": "zh-CN"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 UI 变更
|
||||
|
||||
**变更前**: 两个独立选项
|
||||
```
|
||||
界面语言 简体中文 >
|
||||
AI回复语言 简体中文 >
|
||||
```
|
||||
|
||||
**变更后**: 一个选项
|
||||
```
|
||||
语言 简体中文 >
|
||||
```
|
||||
|
||||
## 4. 文件变更清单
|
||||
|
||||
### 后端 (7 文件)
|
||||
|
||||
| 文件 | 变更 |
|
||||
|------|------|
|
||||
| `backend/src/schemas/shared/user.py` | 字段重命名 |
|
||||
| `backend/src/core/agentscope/runtime/runner.py` | 读取 `language` |
|
||||
| `backend/src/core/agentscope/prompts/system_prompt.py` | 参数重命名 |
|
||||
| `backend/src/core/agentscope/prompts/worker_rules.py` | 参数重命名 |
|
||||
| `backend/src/core/agentscope/prompts/agent_prompt.py` | 参数重命名 |
|
||||
| `backend/tests/unit/test_parse_profile_settings.py` | 测试更新 |
|
||||
| `backend/tests/unit/test_agentscope_prompts.py` | 测试更新 |
|
||||
|
||||
### 数据库 (SQL 直接执行)
|
||||
|
||||
| 操作 | 说明 |
|
||||
|------|------|
|
||||
| Supabase MCP SQL | 更新现有 profiles.settings JSON 结构 |
|
||||
|
||||
### 前端 (6 文件)
|
||||
|
||||
| 文件 | 变更 |
|
||||
|------|------|
|
||||
| `apps/lib/features/settings/data/models/profile_settings.dart` | 字段重命名 |
|
||||
| `apps/lib/features/settings/data/apis/profile_api.dart` | 序列化字段 |
|
||||
| `apps/lib/features/settings/presentation/screens/general_settings_screen.dart` | 移除 AI 语言 UI |
|
||||
| `apps/lib/l10n/app_zh.arb` | 删除旧文案,添加新文案 |
|
||||
| `apps/lib/l10n/app_en.arb` | 删除旧文案,添加新文案 |
|
||||
| `apps/lib/l10n/app_zh_hant.arb` | 删除旧文案,添加新文案 |
|
||||
|
||||
### 协议文档 (2 文件)
|
||||
|
||||
| 文件 | 变更 |
|
||||
|------|------|
|
||||
| `docs/protocols/profile/profile-protocol.md` | 更新字段定义 |
|
||||
| `docs/protocols/divination/divination-run-protocol.md` | 更新语言规则 |
|
||||
|
||||
## 5. 验收标准
|
||||
|
||||
1. 后端 API 只返回 `language` 字段
|
||||
2. 前端设置界面只有一个语言选项
|
||||
3. AI 回复语言跟随 `language` 设置
|
||||
4. 所有测试通过
|
||||
@@ -0,0 +1,334 @@
|
||||
# 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` | 修改 | 新增文案 |
|
||||
@@ -0,0 +1,201 @@
|
||||
# PRD:积分流水列表功能
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
当前应用已有完整的积分账户系统:
|
||||
- 积分账户表:`user_points`
|
||||
- 积分业务流水:`points_ledger`(记录所有积分变动)
|
||||
- 积分余额查询接口:`GET /api/v1/points/balance`
|
||||
- 积分中心页面:`CoinCenterScreen`
|
||||
|
||||
用户在积分中心可以看到余额和购买套餐,但无法查看积分的收支明细。本任务目标是增加积分流水列表功能,让用户了解自己的积分变动历史。
|
||||
|
||||
## 2. 当前事实
|
||||
|
||||
### 2.1 后端现状
|
||||
|
||||
**已有的 `points_ledger` 表结构**:
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `id` | UUID | 主键 |
|
||||
| `user_id` | UUID | 用户 ID |
|
||||
| `direction` | smallint | 方向:1=收入,-1=支出 |
|
||||
| `amount` | bigint | 变动数量(正数) |
|
||||
| `balance_after` | bigint | 变动后余额 |
|
||||
| `change_type` | varchar | 类型:register/consume/adjust/purchase/refund |
|
||||
| `biz_type` | varchar | 业务类型:chat/payment(可空) |
|
||||
| `biz_id` | UUID | 业务 ID(可空) |
|
||||
| `event_id` | varchar | 幂等事件 ID |
|
||||
| `operator_id` | UUID | 操作者 ID(可空) |
|
||||
| `metadata` | jsonb | 扩展元数据 |
|
||||
| `created_at` | timestamptz | 创建时间 |
|
||||
|
||||
**已有的索引**:
|
||||
- `ix_points_ledger_user_created_at`:支持按用户+时间倒序查询
|
||||
- `uq_points_ledger_user_event`:用户+事件唯一约束
|
||||
|
||||
**已有的 Repository 方法**:
|
||||
- `append_ledger()`:写入流水
|
||||
- `has_ledger_event()`:检查事件是否存在
|
||||
|
||||
**缺失**:
|
||||
- 无分页查询流水列表方法
|
||||
- 无 HTTP API 端点
|
||||
|
||||
### 2.2 前端现状
|
||||
|
||||
**已有的积分中心页面**:
|
||||
- `apps/lib/features/settings/presentation/screens/coin_center_screen.dart`
|
||||
- 显示余额和套餐卡片
|
||||
|
||||
**已有的 API 调用**:
|
||||
- `apps/lib/features/points/data/apis/points_api.dart`:仅支持 `getPackages()`
|
||||
|
||||
**缺失**:
|
||||
- 无流水列表 API 调用
|
||||
- 无流水列表页面/组件
|
||||
|
||||
## 3. 需求范围
|
||||
|
||||
### 3.1 必须实现
|
||||
|
||||
**后端**:
|
||||
- 新增 `GET /api/v1/points/ledger` 接口
|
||||
- 支持分页(游标分页,按 `created_at` 倒序)
|
||||
- 返回流水列表,包含:时间、类型、金额、余额、描述
|
||||
|
||||
**前端**:
|
||||
- 在积分中心页面添加「查看流水」入口
|
||||
- 新建流水列表页面,支持分页加载
|
||||
- 流水项展示:类型图标、类型文案、金额(+绿色/-红色)、时间、余额
|
||||
|
||||
### 3.2 本期不做
|
||||
|
||||
- 按类型筛选(可作为后续优化)
|
||||
- 导出流水
|
||||
- 流水详情页
|
||||
|
||||
## 4. 数据契约
|
||||
|
||||
### 4.1 API 接口
|
||||
|
||||
**请求**:
|
||||
```
|
||||
GET /api/v1/points/ledger?limit=20&cursor={created_at_iso}
|
||||
```
|
||||
|
||||
**响应**:
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"id": "uuid",
|
||||
"direction": 1,
|
||||
"amount": 100,
|
||||
"balanceAfter": 500,
|
||||
"changeType": "purchase",
|
||||
"displayText": "购买积分包",
|
||||
"createdAt": "2026-04-28T10:00:00Z"
|
||||
}
|
||||
],
|
||||
"nextCursor": "2026-04-27T10:00:00Z",
|
||||
"hasMore": true
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 change_type 对应展示文案
|
||||
|
||||
| change_type | direction | 展示文案(中文) | 展示文案(英文) |
|
||||
|-------------|-----------|------------------|------------------|
|
||||
| register | 1 | 注册赠送 | Registration bonus |
|
||||
| purchase | 1 | 购买积分包 | Purchase credits |
|
||||
| consume | -1 | AI 对话消耗 | AI chat cost |
|
||||
| adjust | ±1 | 系统调整 | System adjustment |
|
||||
| refund | -1 | 退款 | Refund |
|
||||
|
||||
## 5. 技术方案
|
||||
|
||||
### 5.1 后端实现
|
||||
|
||||
**Repository 层**(`v1/points/repository.py`):
|
||||
```python
|
||||
async def list_ledger(
|
||||
self,
|
||||
*,
|
||||
user_id: UUID,
|
||||
limit: int,
|
||||
cursor: datetime | None = None,
|
||||
) -> tuple[list[PointsLedger], bool]:
|
||||
# 查询 points_ledger 表
|
||||
# 按 created_at DESC 分页
|
||||
# 返回 (items, has_more)
|
||||
```
|
||||
|
||||
**Service 层**(`v1/points/service.py`):
|
||||
```python
|
||||
async def get_ledger_list(
|
||||
self,
|
||||
*,
|
||||
user_id: UUID,
|
||||
limit: int = 20,
|
||||
cursor: str | None = None,
|
||||
) -> LedgerListResult:
|
||||
# 调用 repository
|
||||
# 组装 display_text
|
||||
# 返回响应
|
||||
```
|
||||
|
||||
**Router 层**(`v1/points/router.py`):
|
||||
```python
|
||||
@router.get("/ledger", response_model=LedgerListResponse)
|
||||
async def get_points_ledger(...):
|
||||
...
|
||||
```
|
||||
|
||||
### 5.2 前端实现
|
||||
|
||||
**目录结构**:
|
||||
```
|
||||
apps/lib/features/points/
|
||||
├── data/
|
||||
│ ├── apis/
|
||||
│ │ └── points_api.dart # 新增 getLedger()
|
||||
│ └── models/
|
||||
│ ├── package_info.dart # 已有
|
||||
│ └── ledger_item.dart # 新增
|
||||
└── presentation/
|
||||
└── screens/
|
||||
└── points_ledger_screen.dart # 新增
|
||||
```
|
||||
|
||||
**入口**:
|
||||
- 在 `CoinCenterScreen` 的余额卡片下方添加「查看流水」按钮
|
||||
- 点击后导航到 `PointsLedgerScreen`
|
||||
|
||||
## 6. 实现步骤
|
||||
|
||||
### Phase 1: 后端 API
|
||||
|
||||
1. 新增 Schema:`LedgerItem`、`LedgerListResponse`(`v1/points/schemas.py`)
|
||||
2. 新增 Repository 方法:`list_ledger()`(`v1/points/repository.py`)
|
||||
3. 新增 Service 方法:`get_ledger_list()`(`v1/points/service.py`)
|
||||
4. 新增 Router 端点:`GET /ledger`(`v1/points/router.py`)
|
||||
5. 编写单元测试
|
||||
|
||||
### Phase 2: 前端 UI
|
||||
|
||||
1. 新增 Model:`LedgerItem`(`features/points/data/models/ledger_item.dart`)
|
||||
2. 新增 API 方法:`getLedger()`(`features/points/data/apis/points_api.dart`)
|
||||
3. 新增页面:`PointsLedgerScreen`(`features/points/presentation/screens/points_ledger_screen.dart`)
|
||||
4. 修改 `CoinCenterScreen`,添加入口按钮
|
||||
5. 添加 i18n 文案(`app_zh.arb`、`app_en.arb`、`app_zh_hant.arb`)
|
||||
|
||||
## 7. 验收标准
|
||||
|
||||
- [ ] 后端 API 返回正确的分页数据
|
||||
- [ ] 前端能正确加载并展示流水列表
|
||||
- [ ] 流水类型显示对应文案
|
||||
- [ ] 金额按收支方向显示不同颜色
|
||||
- [ ] 分页加载正常工作
|
||||
- [ ] 无数据时显示空状态
|
||||
- [ ] 加载中显示 loading 状态
|
||||
@@ -0,0 +1,85 @@
|
||||
{
|
||||
"title": "feat: 积分流水列表功能",
|
||||
"slug": "feat-points-ledger",
|
||||
"status": "completed",
|
||||
"created_at": "2026-04-28",
|
||||
"developer": "opencode",
|
||||
"description": "增加积分流水列表功能,入口放在「账号与数据」页面",
|
||||
"prd": "prd.md",
|
||||
"implementation_plan": "IMPLEMENTATION_PLAN.md",
|
||||
"checklist": [
|
||||
{
|
||||
"phase": "backend",
|
||||
"items": [
|
||||
{
|
||||
"task": "新增 LedgerItem、LedgerListResponse Schema",
|
||||
"file": "backend/src/v1/points/schemas.py",
|
||||
"done": true
|
||||
},
|
||||
{
|
||||
"task": "新增 list_ledger() Repository 方法",
|
||||
"file": "backend/src/v1/points/repository.py",
|
||||
"done": true
|
||||
},
|
||||
{
|
||||
"task": "新增 get_ledger_list() Service 方法",
|
||||
"file": "backend/src/v1/points/service.py",
|
||||
"done": true
|
||||
},
|
||||
{
|
||||
"task": "新增 GET /ledger Router 端点",
|
||||
"file": "backend/src/v1/points/router.py",
|
||||
"done": true
|
||||
},
|
||||
{
|
||||
"task": "后端 API 测试通过",
|
||||
"done": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"phase": "frontend",
|
||||
"items": [
|
||||
{
|
||||
"task": "新增 LedgerItem 模型",
|
||||
"file": "apps/lib/features/points/data/models/ledger_item.dart",
|
||||
"done": true
|
||||
},
|
||||
{
|
||||
"task": "新增 getLedger() API 方法",
|
||||
"file": "apps/lib/features/points/data/apis/points_api.dart",
|
||||
"done": true
|
||||
},
|
||||
{
|
||||
"task": "新增 PointsLedgerScreen 页面",
|
||||
"file": "apps/lib/features/points/presentation/screens/points_ledger_screen.dart",
|
||||
"done": true
|
||||
},
|
||||
{
|
||||
"task": "重命名 AccountDeleteScreen → AccountDataScreen,添加积分流水入口",
|
||||
"file": "apps/lib/features/settings/presentation/screens/account_data_screen.dart",
|
||||
"done": true
|
||||
},
|
||||
{
|
||||
"task": "更新 SettingsScreen 导入和调用",
|
||||
"file": "apps/lib/features/settings/presentation/screens/settings_screen.dart",
|
||||
"done": true
|
||||
},
|
||||
{
|
||||
"task": "添加 i18n 文案",
|
||||
"file": "apps/lib/l10n/app_*.arb",
|
||||
"done": true
|
||||
},
|
||||
{
|
||||
"task": "运行 flutter gen-l10n 生成代码",
|
||||
"done": true
|
||||
},
|
||||
{
|
||||
"task": "前端功能测试通过",
|
||||
"done": true
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"completedAt": "2026-04-28"
|
||||
}
|
||||
Reference in New Issue
Block a user