Files
eryao/.trellis/tasks/04-16-starter-package-purchase-tracking/prd.md
T
qzl ff40ff9dd8 feat: 新人初始礼包购买追踪功能
- 数据库:添加 has_purchased_starter_pack 字段到 register_bonus_claims
- 后端:创建静态配置管理套餐信息,支持按国家/地区区分
- 后端:新增 GET /api/v1/points/packages API 返回可用套餐
- 后端:创建 utils/paths.py 统一路径管理
- 前端:动态获取套餐信息,移除硬编码
- 前端:添加 ProductCode 枚举约束,前后端类型安全
- 配置:Profile 默认国家改为 US(ISO 3166-1 alpha-2)
- 文档:更新协议文档说明新 API 和字段
2026-04-16 16:11:09 +08:00

477 lines
12 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.
# 新人初始礼包购买追踪功能
## 1. 需求概述
### 1.1 背景
用户需要追踪是否已购买新人初始礼包($0.99/60积分),以便前端支付页面决定是否展示该礼包。同时需要将前端硬编码的套餐信息改为后端动态配置,支持按国家/地区区分不同套餐。
### 1.2 核心需求
1.`register_bonus_claims` 表添加字段,标记是否购买了新人初始礼包
2. 后端提供统一的套餐信息 API,包含新人礼包资格检查
3. 前端从后端动态获取套餐信息,不再硬编码
4. 支持按国家/地区(ISO 3166-1 alpha-2)提供不同套餐配置
5. 修改用户 profile 的默认国家/地区为中国改为美国
6. **本期不实现**:支付流程、支付验证逻辑
### 1.3 业务规则
- 同一邮箱只能购买一次新人礼包
- 删除账号后同邮箱重新注册,不刷新新人礼包资格
- 新人礼包规格:$0.99 / 60 积分(美国区)
- 不同国家/地区可配置不同套餐和价格
### 1.4 国家/地区标识符
采用 **ISO 3166-1 alpha-2** 标准(两位字母代码):
| 代码 | 国家/地区 |
|------|----------|
| `US` | 美国(默认) |
| `CN` | 中国大陆 |
| `TW` | 台湾 |
| `HK` | 香港 |
| `JP` | 日本 |
## 2. 技术方案
### 2.1 静态配置文件
#### 文件路径
```
backend/src/core/config/static/packages/
├── us.yaml # 美国区套餐配置
├── cn.yaml # 中国区套餐配置(预留)
└── default.yaml # 默认配置(无匹配时使用)
```
#### 配置格式(us.yaml
```yaml
region: US
currency: USD
packages:
- product_code: new_user_pack_099_60
type: starter
price_usd: "0.99"
credits: 60
badge: null
sort_order: 0
enabled: true
- product_code: basic_pack_499_100
type: regular
price_usd: "4.99"
credits: 100
badge: null
sort_order: 10
enabled: true
- product_code: popular_pack_799_210
type: regular
price_usd: "7.99"
credits: 210
badge: "Popular"
sort_order: 20
enabled: true
- product_code: premium_pack_1299_415
type: regular
price_usd: "12.99"
credits: 415
badge: null
sort_order: 30
enabled: true
```
#### 配置字段说明
| 字段 | 类型 | 说明 |
|------|------|------|
| `region` | string | ISO 3166-1 alpha-2 国家代码 |
| `currency` | string | 货币代码(ISO 4217 |
| `packages` | array | 套餐列表 |
| `packages[].product_code` | string | 产品唯一标识 |
| `packages[].type` | string | `starter`(新人)或 `regular`(常规) |
| `packages[].price_usd` | string | 价格(美元) |
| `packages[].credits` | int | 积分数量 |
| `packages[].badge` | string? | 标签(如 "Popular" |
| `packages[].sort_order` | int | 排序权重 |
| `packages[].enabled` | bool | 是否启用 |
### 2.2 数据库变更
#### 表:`register_bonus_claims`
当前结构:
```sql
- id: UUID PK
- email_hash: VARCHAR(64) UNIQUE
- user_email_snapshot: TEXT
- first_user_id_snapshot: UUID NULL
- balance_snapshot: BIGINT NULL
- grant_event_id: VARCHAR(64) UNIQUE
- created_at / updated_at
```
新增字段:
```sql
- has_purchased_starter_pack: BOOLEAN NOT NULL DEFAULT FALSE
-- 标记是否已购买新人初始礼包
```
#### 迁移文件
- 文件名:`20260416_0001_add_starter_pack_tracking.py`
- 路径:`backend/alembic/versions/`
### 2.3 后端实现
#### 2.3.1 配置加载层
新建目录结构:
```
backend/src/core/config/packages/
├── __init__.py
├── loader.py # 配置加载器
├── schema.py # Pydantic 模型
└── registry.py # 配置注册表
```
文件:`backend/src/core/config/packages/schema.py`
```python
from decimal import Decimal
from enum import Enum
from pydantic import BaseModel
class PackageType(str, Enum):
STARTER = "starter"
REGULAR = "regular"
class PackageConfig(BaseModel):
product_code: str
type: PackageType
price_usd: Decimal
credits: int
badge: str | None = None
sort_order: int = 0
enabled: bool = True
class RegionPackagesConfig(BaseModel):
region: str
currency: str
packages: list[PackageConfig]
```
#### 2.3.2 Model 层
文件:`backend/src/models/register_bonus_claims.py`
新增字段:
```python
has_purchased_starter_pack: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False
)
```
#### 2.3.3 Repository 层
文件:`backend/src/v1/points/repository.py`
新增方法:
```python
async def has_purchased_starter_pack(
self,
*,
email_hash: str,
) -> bool:
"""Check if user has purchased starter pack"""
claim = await self.get_register_bonus_claim(email_hash=email_hash)
if claim is None:
return False
return bool(claim.has_purchased_starter_pack)
```
#### 2.3.4 Service 层
文件:`backend/src/v1/points/service.py`
新增方法:
```python
async def get_available_packages(
self,
*,
country: str,
user_email: str,
) -> PackagesResult:
"""Get available packages for user's region with eligibility info"""
config = get_packages_config_for_region(country)
email_hash = self._build_register_bonus_email_hash(
self._normalize_email(user_email)
)
has_starter = await self._repository.has_purchased_starter_pack(
email_hash=email_hash
)
packages = []
for pkg in config.packages:
if not pkg.enabled:
continue
if pkg.type == PackageType.STARTER and has_starter:
continue
packages.append(PackageInfo(
product_code=pkg.product_code,
type=pkg.type,
price_usd=pkg.price_usd,
credits=pkg.credits,
badge=pkg.badge,
is_starter=pkg.type == PackageType.STARTER,
starter_eligible=(pkg.type == PackageType.STARTER and not has_starter),
))
return PackagesResult(
region=config.region,
currency=config.currency,
packages=sorted(packages, key=lambda p: p.sort_order),
)
```
#### 2.3.5 Schema 层
文件:`backend/src/v1/points/schemas.py`
新增:
```python
class PackageInfo(BaseModel):
productCode: str
type: Literal["starter", "regular"]
priceUsd: Decimal
credits: int
badge: str | None = None
isStarter: bool
starterEligible: bool
sortOrder: int
class PackagesResponse(BaseModel):
region: str
currency: str
packages: list[PackageInfo]
```
#### 2.3.6 Router 层
文件:`backend/src/v1/points/router.py`
**删除**原计划的独立路由 `/starter-pack/eligibility`
新增统一路由:
```python
@router.get("/packages", response_model=PackagesResponse)
async def get_available_packages(
service: Annotated[PointsService, Depends(get_points_service)],
current_user: Annotated[CurrentUser, Depends(get_current_user)],
) -> PackagesResponse:
"""Get available packages for current user's region"""
country = current_user.settings.get("preferences", {}).get("country", "US")
result = await service.get_available_packages(
country=country,
user_email=current_user.email,
)
return PackagesResponse(
region=result.region,
currency=result.currency,
packages=[
PackageInfo(
productCode=pkg.product_code,
type=pkg.type,
priceUsd=pkg.price_usd,
credits=pkg.credits,
badge=pkg.badge,
isStarter=pkg.is_starter,
starterEligible=pkg.starter_eligible,
sortOrder=pkg.sort_order,
)
for pkg in result.packages
],
)
```
### 2.4 前端实现
#### 2.4.1 API 调用
文件:`apps/lib/features/points/data/packages_repository.dart`
```dart
Future<PackagesResult> getPackages() async {
final response = await _dio.get('/api/v1/points/packages');
return PackagesResult.fromJson(response.data);
}
```
#### 2.4.2 数据模型
文件:`apps/lib/features/points/data/models/package_info.dart`
```dart
enum PackageType { starter, regular }
class PackageInfo {
final String productCode;
final PackageType type;
final Decimal priceUsd;
final int credits;
final String? badge;
final bool isStarter;
final bool starterEligible;
final int sortOrder;
// ...
}
```
#### 2.4.3 UI 展示逻辑
文件:`apps/lib/features/settings/presentation/screens/coin_center_screen.dart`
修改逻辑:
- 页面加载时调用 `GET /api/v1/points/packages`
- 遍历 `packages` 列表动态渲染卡片
- `isStarter=true``starterEligible=true` 时展示新人礼包
- `isStarter=true``starterEligible=false` 时跳过(已购买)
- 移除硬编码的套餐数据
### 2.5 Profile 默认值修改
#### 后端
文件:`backend/src/schemas/domain/profile.py`(如存在)
修改默认值:
```python
class PreferenceSettings(BaseModel):
interface_language: str = "zh-CN"
ai_language: str = "zh-CN"
timezone: str = "Asia/Shanghai"
country: str = "US" # 从 "CN" 改为 "US"
```
#### 前端
文件:`apps/lib/features/settings/data/models/profile_settings.dart`
修改默认值:
```dart
class PreferenceSettings {
const PreferenceSettings({
this.interfaceLanguage = 'zh-CN',
this.aiLanguage = 'zh-CN',
this.timezone = 'Asia/Shanghai',
this.country = 'US', // 从 'CN' 改为 'US'
});
// ...
}
```
### 2.6 协议文档更新
文件:`docs/protocols/common/user-points-chat-data-protocol.md`
新增章节:
- `GET /api/v1/points/packages` API 说明
- `has_purchased_starter_pack` 字段说明
- 静态配置文件格式说明
## 3. 实现步骤
### Phase 1: 数据库与配置层
1. 创建静态配置文件(`us.yaml``default.yaml`
2. 创建配置加载器(`backend/src/core/config/packages/`
3. 创建 Alembic 迁移文件
4. 更新 `RegisterBonusClaims` model
5. 运行迁移验证
### Phase 2: 后端 API
1. 更新 schemas`PackagesResponse``PackageInfo`
2. 更新 repository
3. 更新 service
4. 更新 router(统一 `/packages` 路由)
5. 编写单元测试
6. 本地验证 API
### Phase 3: Profile 默认值
1. 修改后端 schema 默认值
2. 修改前端 model 默认值
3. 验证新用户注册后的默认国家
### Phase 4: 前端集成
1. 创建数据模型(`PackageInfo``PackagesResult`
2. 创建 API 调用层
3. 更新 `CoinCenterScreen` 动态渲染
4. 移除硬编码套餐数据
5. 本地测试
### Phase 5: 文档与验证
1. 更新协议文档
2. 更新 HTTP error codes(如需要)
3. 集成测试
4. Code review
## 4. 测试计划
### 4.1 后端单元测试
文件:`backend/tests/unit/test_packages_service.py`
测试用例:
- 美国区用户获取套餐列表
- 新人礼包未购买 → 列表中包含 starter 包
- 新人礼包已购买 → 列表中不含 starter 包
- 未配置地区 → 使用默认配置
### 4.2 后端集成测试
文件:`backend/tests/integration/test_packages_api.py`
测试用例:
- 注册用户查询套餐
- 不同国家用户获取不同配置
- 购买后再次查询
### 4.3 前端测试
- 页面加载正确调用 API
- 动态渲染套餐卡片
- 新人礼包展示/隐藏逻辑
## 5. 风险与限制
### 5.1 本期限制
- 不实现支付流程
- 不实现支付验证
- `has_purchased_starter_pack` 字段暂时只读,后续支付流程会写入
- 仅实现美国区配置,其他地区预留
### 5.2 后续扩展
- 接入 iOS IAP 支付
- 实现支付验证与入账
- 实现退款冲正
- 添加其他国家/地区配置
- 价格本地化(多币种支持)
## 6. 参考文档
- 完整支付计划:`docs/plans/ios-new-user-pack-payment-plan.md`
- 数据协议:`docs/protocols/common/user-points-chat-data-protocol.md`
- HTTP 错误码:`docs/protocols/common/http-error-codes.md`
- ISO 3166-1 alpha-2: https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2