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 和字段
This commit is contained in:
@@ -0,0 +1,476 @@
|
||||
# 新人初始礼包购买追踪功能
|
||||
|
||||
## 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
|
||||
@@ -0,0 +1,153 @@
|
||||
{
|
||||
"id": "starter-package-purchase-tracking",
|
||||
"name": "starter-package-purchase-tracking",
|
||||
"title": "新人初始礼包购买追踪 + 动态套餐配置",
|
||||
"description": "在 register_bonus_claims 表添加字段追踪是否购买新人初始礼包,创建静态配置文件管理套餐,提供统一的套餐信息 API",
|
||||
"status": "planning",
|
||||
"dev_type": "fullstack",
|
||||
"scope": ["backend", "apps"],
|
||||
"priority": "P2",
|
||||
"creator": "zl-q",
|
||||
"assignee": "zl-q",
|
||||
"createdAt": "2026-04-16",
|
||||
"completedAt": null,
|
||||
"branch": "worktree/feat-starter-package-purchase-tracking",
|
||||
"base_branch": "dev",
|
||||
"worktree_path": "/home/qzl/Code/eryao/.worktrees/feat-starter-package-purchase-tracking",
|
||||
"current_phase": 0,
|
||||
"next_action": [
|
||||
{
|
||||
"phase": 1,
|
||||
"action": "implement"
|
||||
},
|
||||
{
|
||||
"phase": 2,
|
||||
"action": "check"
|
||||
},
|
||||
{
|
||||
"phase": 3,
|
||||
"action": "finish"
|
||||
},
|
||||
{
|
||||
"phase": 4,
|
||||
"action": "create-pr"
|
||||
}
|
||||
],
|
||||
"commit": null,
|
||||
"pr_url": null,
|
||||
"subtasks": [
|
||||
{
|
||||
"id": "static-config",
|
||||
"title": "静态配置:创建套餐配置文件(us.yaml, default.yaml)",
|
||||
"status": "completed",
|
||||
"phase": 1
|
||||
},
|
||||
{
|
||||
"id": "config-loader",
|
||||
"title": "配置加载层:创建 packages loader/schema/registry",
|
||||
"status": "pending",
|
||||
"phase": 1
|
||||
},
|
||||
{
|
||||
"id": "db-migration",
|
||||
"title": "数据库迁移:添加 has_purchased_starter_pack 字段",
|
||||
"status": "pending",
|
||||
"phase": 1
|
||||
},
|
||||
{
|
||||
"id": "backend-model",
|
||||
"title": "后端 Model 层:更新 RegisterBonusClaims",
|
||||
"status": "pending",
|
||||
"phase": 1
|
||||
},
|
||||
{
|
||||
"id": "backend-repository",
|
||||
"title": "后端 Repository 层:添加查询方法",
|
||||
"status": "pending",
|
||||
"phase": 1
|
||||
},
|
||||
{
|
||||
"id": "backend-service",
|
||||
"title": "后端 Service 层:实现套餐获取与资格检查逻辑",
|
||||
"status": "pending",
|
||||
"phase": 1
|
||||
},
|
||||
{
|
||||
"id": "backend-schema",
|
||||
"title": "后端 Schema 层:定义 PackagesResponse/PackageInfo",
|
||||
"status": "pending",
|
||||
"phase": 1
|
||||
},
|
||||
{
|
||||
"id": "backend-router",
|
||||
"title": "后端 Router 层:添加统一 GET /packages 路由",
|
||||
"status": "pending",
|
||||
"phase": 1
|
||||
},
|
||||
{
|
||||
"id": "profile-default-country",
|
||||
"title": "Profile 默认值:country 从 CN 改为 US(前后端)",
|
||||
"status": "pending",
|
||||
"phase": 1
|
||||
},
|
||||
{
|
||||
"id": "backend-tests",
|
||||
"title": "后端单元测试",
|
||||
"status": "pending",
|
||||
"phase": 1
|
||||
},
|
||||
{
|
||||
"id": "frontend-models",
|
||||
"title": "前端数据模型:PackageInfo/PackagesResult",
|
||||
"status": "pending",
|
||||
"phase": 1
|
||||
},
|
||||
{
|
||||
"id": "frontend-api",
|
||||
"title": "前端 API 调用层",
|
||||
"status": "pending",
|
||||
"phase": 1
|
||||
},
|
||||
{
|
||||
"id": "frontend-ui",
|
||||
"title": "前端 UI:CoinCenterScreen 动态渲染套餐",
|
||||
"status": "pending",
|
||||
"phase": 1
|
||||
},
|
||||
{
|
||||
"id": "docs-protocol",
|
||||
"title": "更新协议文档",
|
||||
"status": "pending",
|
||||
"phase": 1
|
||||
},
|
||||
{
|
||||
"id": "integration-test",
|
||||
"title": "集成测试验证",
|
||||
"status": "pending",
|
||||
"phase": 2
|
||||
}
|
||||
],
|
||||
"children": [],
|
||||
"parent": null,
|
||||
"relatedFiles": [
|
||||
"backend/src/core/config/static/packages/us.yaml",
|
||||
"backend/src/core/config/static/packages/default.yaml",
|
||||
"backend/src/core/config/packages/loader.py",
|
||||
"backend/src/core/config/packages/schema.py",
|
||||
"backend/src/core/config/packages/registry.py",
|
||||
"backend/src/models/register_bonus_claims.py",
|
||||
"backend/src/v1/points/repository.py",
|
||||
"backend/src/v1/points/service.py",
|
||||
"backend/src/v1/points/schemas.py",
|
||||
"backend/src/v1/points/router.py",
|
||||
"apps/lib/features/settings/data/models/profile_settings.dart",
|
||||
"apps/lib/features/settings/presentation/screens/coin_center_screen.dart",
|
||||
"docs/protocols/common/user-points-chat-data-protocol.md"
|
||||
],
|
||||
"notes": "本期不实现支付流程,仅提供套餐信息查询 API。采用 ISO 3166-1 alpha-2 国家代码。",
|
||||
"meta": {
|
||||
"country_code_standard": "ISO 3166-1 alpha-2",
|
||||
"default_country": "US",
|
||||
"packages_count": 4
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
<!-- @@@auto:current-status -->
|
||||
- **Active File**: `journal-1.md`
|
||||
- **Total Sessions**: 9
|
||||
- **Total Sessions**: 1
|
||||
- **Last Active**: 2026-04-16
|
||||
<!-- @@@/auto:current-status -->
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
<!-- @@@auto:active-documents -->
|
||||
| File | Lines | Status |
|
||||
|------|-------|--------|
|
||||
| `journal-1.md` | ~477 | Active |
|
||||
| `journal-1.md` | ~80 | Active |
|
||||
<!-- @@@/auto:active-documents -->
|
||||
|
||||
---
|
||||
@@ -29,21 +29,13 @@
|
||||
<!-- @@@auto:session-history -->
|
||||
| # | Date | Title | Commits |
|
||||
|---|------|-------|---------|
|
||||
| 9 | 2026-04-16 | 起卦教程首次访问追踪 + Agent时间上下文 | `69b34bd` |
|
||||
| 8 | 2026-04-15 | Session deletion anonymization for iOS compliance | `c2b726e` |
|
||||
| 7 | 2026-04-15 | 六爻算法修复 + Prompt架构重构 + i18n输出规则 | `9598d16`, `be68681` |
|
||||
| 6 | 2026-04-13 | 修复追问链路与上限判定 | - |
|
||||
| 5 | 2026-04-13 | feat: 邀请码显示功能 - 后端API + 前端对接 | - |
|
||||
| 4 | 2026-04-13 | 绑定积分重注册余额恢复提交 | `c55be6d` |
|
||||
| 3 | 2026-04-13 | 积分重注册余额恢复验证 | - |
|
||||
| 2 | 2026-04-10 | 静态通知同步 + 积分审计 bug 修复 | `3f3d613` |
|
||||
| 1 | 2026-04-10 | 实现站内通知系统 | `3f3d613` |
|
||||
| 1 | 2026-04-16 | 新人初始礼包购买追踪功能 - 计划制定 | - |
|
||||
<!-- @@@/auto:session-history -->
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- Worktree workspace for: `worktree/feat-starter-package-purchase-tracking`
|
||||
- Sessions are appended to journal files
|
||||
- New journal file created when current exceeds 2000 lines
|
||||
- Use `add_session.py` to record sessions
|
||||
@@ -1,477 +1,84 @@
|
||||
# Journal - zl-q (Part 1)
|
||||
# Journal - zl-q
|
||||
|
||||
> AI development session journal
|
||||
> Started: 2026-04-10
|
||||
> Development session records for worktree: feat-starter-package-purchase-tracking
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
## Session 1: 实现站内通知系统
|
||||
|
||||
**Date**: 2026-04-10
|
||||
**Task**: 实现站内通知系统
|
||||
|
||||
### Summary
|
||||
|
||||
(Add summary)
|
||||
|
||||
### Main Changes
|
||||
|
||||
## 完成内容
|
||||
|
||||
| 模块 | 描述 |
|
||||
|------|------|
|
||||
| 协议文档 | `docs/protocols/notification/notification-inbox-protocol.md` 及 `http-error-codes.md` 新增 `NOTIFICATION_NOT_FOUND` |
|
||||
| 数据库迁移 | `notifications` + `user_notifications` 两张表, RLS 策略, 索引 |
|
||||
| 后端 ORM | `Notification(TimestampMixin, SoftDeleteMixin, Base)` + `UserNotification(TimestampMixin, Base)` |
|
||||
| 后端 API | schema/repository/service/router 全套, 4 个端点 (列表/未读数/单条已读/全部已读) |
|
||||
| 后端测试 | 19 个单元测试覆盖: 列表权限隔离, 未读数统计, 幂等已读, 越权拒绝, 撤销/删除过滤, payload 解析 |
|
||||
| Flutter models | `NotificationPayload` sealed class (none/open_route/open_url) + `NotificationItem` + `NotificationListResult` |
|
||||
| Flutter API | `NotificationApi` (list/unreadCount/markRead/markAllRead) |
|
||||
| Flutter Repository | 抽象接口 + `NotificationRepositoryImpl` |
|
||||
| Flutter Bloc | `NotificationBloc` (ChangeNotifier) 含 Realtime 事件处理 |
|
||||
| Flutter UI | `NotificationCenterScreen` + `NotificationListItem` + 首页 badge 集成 |
|
||||
| Flutter 测试 | 14 个测试: payload 解析 6 个 + bloc 状态管理 8 个 |
|
||||
|
||||
## 验收标准对照
|
||||
|
||||
- [x] 能为指定用户写入一条站内通知 (ORM + migration 就绪)
|
||||
- [x] 用户能看到自己的通知列表 (GET /notifications)
|
||||
- [x] 用户点击通知后可标记为已读 (PATCH /notifications/{id}/read)
|
||||
- [x] "全部已读"后未读数归零 (PATCH /notifications/mark-all-read)
|
||||
- [x] 用户 A 不能读取或修改用户 B 的通知 (service 层 user_id 来自 JWT, 测试覆盖)
|
||||
- [x] 已读接口重复调用不会报错 (幂等实现, 测试覆盖)
|
||||
- [x] 首页 badge 会随未读数自动更新 (NotificationBloc + ListenableBuilder)
|
||||
- [x] 撤销或统一删除主通知后, 用户侧列表不再展示 (repository 过滤 status+deleted_at)
|
||||
|
||||
|
||||
### Git Commits
|
||||
|
||||
| Hash | Message |
|
||||
|------|---------|
|
||||
| `3f3d613` | (see git log) |
|
||||
|
||||
### Testing
|
||||
|
||||
- [OK] (Add test results)
|
||||
|
||||
### Status
|
||||
|
||||
[OK] **Completed**
|
||||
|
||||
### Next Steps
|
||||
|
||||
- None - task complete
|
||||
|
||||
|
||||
## Session 2: 静态通知同步 + 积分审计 bug 修复
|
||||
|
||||
**Date**: 2026-04-10
|
||||
**Task**: 静态通知同步 + 积分审计 bug 修复
|
||||
|
||||
### Summary
|
||||
|
||||
(Add summary)
|
||||
|
||||
### Main Changes
|
||||
|
||||
## 完成内容
|
||||
|
||||
| 功能 | 说明 |
|
||||
|------|------|
|
||||
| 静态通知 Pydantic Schema | `static_schema.py` — 含 `deleted` 字段支持显式软删除 |
|
||||
| 静态通知同步逻辑 | `static_sync.py` — 创建、更新、撤销、软删除、prune、reconcile-targets |
|
||||
| CLI 命令 | `sync-notifications` 支持 `--path/--source-key/--dry-run/--prune/--reconcile-targets` |
|
||||
| 数据库迁移 | 新增 `source/source_key/source_version/content_hash` 字段 |
|
||||
| 示例通知 | `welcome_points.yaml` |
|
||||
| 触发脚本 | `infra/scripts/register-notifications.sh` |
|
||||
| 协议文档 | `static-notification-sync-protocol.md` |
|
||||
| Bug 修复 | `AuditLedgerMetadata` 未序列化直接写 JSONB → `.model_dump(mode="json")` |
|
||||
| 单测 | 28 passed(`test_static_notification_sync.py`、`test_notification_service.py`) |
|
||||
| 冒烟验证 | 注册→通知→未读数→reconcile-targets→prune 全链路通过 |
|
||||
|
||||
**未完成**:
|
||||
- `notification_updated` Realtime 事件链路
|
||||
- Flutter 端通知中心页面、badge、Realtime 订阅
|
||||
|
||||
**新增文件**:
|
||||
- `backend/src/core/config/notification/__init__.py`
|
||||
- `backend/src/core/config/notification/static_schema.py`
|
||||
- `backend/src/core/config/notification/static_sync.py`
|
||||
- `backend/src/core/config/static/notification/notifications/welcome_points.yaml`
|
||||
- `backend/alembic/versions/20260411_0005_add_notification_static_sync_fields.py`
|
||||
- `backend/tests/unit/test_static_notification_sync.py`
|
||||
- `docs/protocols/notification/static-notification-sync-protocol.md`
|
||||
- `infra/scripts/register-notifications.sh`
|
||||
|
||||
**修改文件**:
|
||||
- `backend/src/core/runtime/cli.py`
|
||||
- `backend/src/models/notification.py`
|
||||
- `backend/src/v1/points/repository.py`
|
||||
- `docs/plans/static-notification-sync-plan.md`
|
||||
|
||||
|
||||
### Git Commits
|
||||
|
||||
| Hash | Message |
|
||||
|------|---------|
|
||||
| `3f3d613` | (see git log) |
|
||||
|
||||
### Testing
|
||||
|
||||
- [OK] (Add test results)
|
||||
|
||||
### Status
|
||||
|
||||
[OK] **Completed**
|
||||
|
||||
### Next Steps
|
||||
|
||||
- None - task complete
|
||||
|
||||
|
||||
## Session 3: 积分重注册余额恢复验证
|
||||
|
||||
**Date**: 2026-04-13
|
||||
**Task**: 积分重注册余额恢复验证
|
||||
|
||||
### Summary
|
||||
|
||||
完成 register_bonus_claims 快照方案的本地迁移与集成测试验证,确认删除账号后重注册可恢复删除前积分余额。
|
||||
|
||||
### Main Changes
|
||||
|
||||
| Feature | Description |
|
||||
|---------|-------------|
|
||||
| DB Migration | 通过 `dev-migrate.sh migrate` 应用 `20260413_0004_register_bonus_claims_snapshot`,新增 `first_user_id_snapshot` 与 `balance_snapshot`,移除 `first_user_id`。 |
|
||||
| Restore Logic | 注册流程优先读取 `balance_snapshot` 恢复余额;删除账号前写入当前余额快照。 |
|
||||
| Integration Tests | 新增未消费删号重注册恢复场景,并更新已有消费后删号重注册断言。 |
|
||||
|
||||
**Updated Files**:
|
||||
- `backend/alembic/versions/20260413_0004_register_bonus_claims_snapshot.py`
|
||||
- `backend/src/models/register_bonus_claims.py`
|
||||
- `backend/src/v1/points/repository.py`
|
||||
- `backend/src/v1/points/service.py`
|
||||
- `backend/src/v1/users/service.py`
|
||||
- `backend/tests/integration/test_register_run_delete_reregister.py`
|
||||
- `backend/tests/unit/test_points_service_audit.py`
|
||||
- `docs/protocols/common/user-points-chat-data-protocol.md`
|
||||
|
||||
**Verification**:
|
||||
- `uv run pytest backend/tests/unit/test_points_service_audit.py` -> 5 passed
|
||||
- `uv run pytest backend/tests/integration/test_register_run_delete_reregister.py` -> 2 passed
|
||||
- Supabase MCP 查询确认 `register_bonus_claims.balance_snapshot` 已写入并与测试行为一致。
|
||||
|
||||
|
||||
### Git Commits
|
||||
|
||||
(No commits - planning session)
|
||||
|
||||
### Testing
|
||||
|
||||
- [OK] (Add test results)
|
||||
|
||||
### Status
|
||||
|
||||
[OK] **Completed**
|
||||
|
||||
### Next Steps
|
||||
|
||||
- None - task complete
|
||||
|
||||
|
||||
## Session 4: 绑定积分重注册余额恢复提交
|
||||
|
||||
**Date**: 2026-04-13
|
||||
**Task**: 绑定积分重注册余额恢复提交
|
||||
|
||||
### Summary
|
||||
|
||||
将删除账号后重注册积分余额恢复 feature 的代码提交与 session 记录绑定。
|
||||
|
||||
### Main Changes
|
||||
|
||||
| Item | Details |
|
||||
|------|---------|
|
||||
| Feature Commit | `c55be6d` |
|
||||
| Scope | register_bonus_claims 快照字段、删号前余额快照、重注册余额恢复、相关测试与协议更新 |
|
||||
|
||||
**Validation**:
|
||||
- pre-commit hooks passed during commit
|
||||
- integration: `backend/tests/integration/test_register_run_delete_reregister.py` passed (`2 passed`)
|
||||
|
||||
|
||||
### Git Commits
|
||||
|
||||
| Hash | Message |
|
||||
|------|---------|
|
||||
| `c55be6d` | (see git log) |
|
||||
|
||||
### Testing
|
||||
|
||||
- [OK] (Add test results)
|
||||
|
||||
### Status
|
||||
|
||||
[OK] **Completed**
|
||||
|
||||
### Next Steps
|
||||
|
||||
- None - task complete
|
||||
|
||||
|
||||
## Session 5: feat: 邀请码显示功能 - 后端API + 前端对接
|
||||
|
||||
**Date**: 2026-04-13
|
||||
**Task**: feat: 邀请码显示功能 - 后端API + 前端对接
|
||||
|
||||
### Summary
|
||||
|
||||
(Add summary)
|
||||
|
||||
### Main Changes
|
||||
|
||||
## Backend 新增 (src/v1/invite/)
|
||||
|
||||
| 文件 | 描述 |
|
||||
|------|------|
|
||||
| schemas.py | `MyInviteCodeResponse` (code, used_count) |
|
||||
| repository.py | `InviteCodeRepository.get_by_owner_id()` |
|
||||
| service.py | `InviteCodeService.get_my_invite_code()` |
|
||||
| dependencies.py | 依赖注入 |
|
||||
| router.py | `GET /api/v1/invite/me` |
|
||||
|
||||
修改: `src/v1/router.py` - 注册 invite_router
|
||||
|
||||
## Frontend 新增/修改 (apps/lib/features/settings/)
|
||||
|
||||
新增:
|
||||
- `data/models/my_invite_code.dart` - `MyInviteCode` 数据模型
|
||||
- `data/apis/invite_api.dart` - API 调用
|
||||
- `data/repositories/invite_repository.dart` - Repository 封装
|
||||
|
||||
修改:
|
||||
- `invite_screen.dart` - 移除 mock 数据,改为调用真实 API,增加 loading/error 状态
|
||||
- `settings_screen.dart` - 接收 `InviteRepository` 参数
|
||||
- `home_screen.dart` - 创建并传递 `InviteRepository` 实例
|
||||
|
||||
**验证**: ruff check ✅ / flutter analyze ✅
|
||||
|
||||
|
||||
### Git Commits
|
||||
|
||||
(No commits - planning session)
|
||||
|
||||
### Testing
|
||||
|
||||
- [OK] (Add test results)
|
||||
|
||||
### Status
|
||||
|
||||
[OK] **Completed**
|
||||
|
||||
### Next Steps
|
||||
|
||||
- None - task complete
|
||||
|
||||
|
||||
## Session 6: 修复追问链路与上限判定
|
||||
|
||||
**Date**: 2026-04-13
|
||||
**Task**: 修复追问链路与上限判定
|
||||
|
||||
### Summary
|
||||
|
||||
定位并修复 follow_up 上下文解析报错;将追问上限改为基于 assistant 回复数;补充单测与集成测试并通过。
|
||||
|
||||
### Main Changes
|
||||
|
||||
| 项目 | 说明 |
|
||||
|------|------|
|
||||
| 追问报错根因 | `worker-agent.log` 中 `AgentChatMessageMetadata` 校验失败,原因是历史 metadata 存在 snake_case 字段与 alias 契约不一致 |
|
||||
| 结构修复 | `divination` 相关模型启用 `populate_by_name=True`,允许 snake_case/alias 一致解析;用户消息缓存写入统一 `by_alias=True` |
|
||||
| 上限逻辑 | 会话运行上限由按 user 消息数改为按 assistant 消息数统计 |
|
||||
| 回归测试 | 新增 `backend/tests/unit/test_runtime_context_messages.py` 覆盖 snake_case metadata 场景 |
|
||||
| 集成测试 | 新增 `backend/tests/integration/test_follow_up_flow.py`,验证 chat->follow_up 成功、assistant=2 后再次 follow_up 返回 409 |
|
||||
|
||||
**验证结果**:
|
||||
- `uv run ruff check`(相关文件)通过
|
||||
- `uv run pytest backend/tests/unit/test_runtime_context_messages.py backend/tests/unit/test_runtime_models_worker_output.py backend/tests/unit/test_history_message_schema.py` 通过
|
||||
- `./infra/scripts/app.sh restart` 后,`uv run pytest backend/tests/integration/test_follow_up_flow.py` 通过
|
||||
|
||||
|
||||
### Git Commits
|
||||
|
||||
(No commits - planning session)
|
||||
|
||||
### Testing
|
||||
|
||||
- [OK] (Add test results)
|
||||
|
||||
### Status
|
||||
|
||||
[OK] **Completed**
|
||||
|
||||
### Next Steps
|
||||
|
||||
- None - task complete
|
||||
|
||||
|
||||
## Session 7: 六爻算法修复 + Prompt架构重构 + i18n输出规则
|
||||
|
||||
**Date**: 2026-04-15
|
||||
**Task**: 六爻算法修复 + Prompt架构重构 + i18n输出规则
|
||||
|
||||
### Summary
|
||||
|
||||
(Add summary)
|
||||
|
||||
### Main Changes
|
||||
|
||||
## 概述
|
||||
|
||||
本次会话完成了六爻核心算法的全面审计修复、prompt架构重构清理、以及多语言输出规则适配。
|
||||
|
||||
### 算法修复 (P0/P1)
|
||||
|
||||
| 问题 | 修复内容 |
|
||||
|------|----------|
|
||||
| P0-1 空亡判断 | 改为仅从日柱计算,年月空亡仅标注不断事 |
|
||||
| P0-2 暗动逻辑 | 重写为静爻+旺相+日冲三条件 |
|
||||
| P1-1 月破 | 独立标注 |
|
||||
| P1-2 动不为空/旺不为空 | 补充判断 |
|
||||
| P1-3 三合局 | 新增判断逻辑 |
|
||||
| P1-4 反吟伏吟 | 新增判断逻辑 |
|
||||
| P1-5 日辰十二长生 | 新增字段 `riChenZhangSheng` |
|
||||
| P1-6 回头生克 | 新增判断逻辑 |
|
||||
|
||||
### Prompt架构重构
|
||||
|
||||
- **删除 `_build_env_section`**:不再向prompt泄露用户上下文(user_context、timezone、client_time等)
|
||||
- **简化语言判断**:删除 `if is_chinese` 分支,`_LANGUAGE_LABELS` 已覆盖全部语言映射
|
||||
- **安全规则改为六爻专属**:只回答六爻占卜相关问题,拒绝无关提问
|
||||
- **`_WORKER_OUTPUT_RULES` 多语言适配**:zh-CN/zh-Hant/en 三版本,按 `ai_language` 分发
|
||||
- **`_WORKER_ROLE_PLAYING` 始终中文**:保证六爻专业性不受语言切换影响
|
||||
- **`sign_level` 枚举统一**:所有语言版本强制使用简体中文枚举值(上上签/中上签/中下签/下下签),前端负责显示映射
|
||||
- **`worker_rules.py` 独立文件**管理多语言规则
|
||||
|
||||
### 清理死代码
|
||||
|
||||
- 删除 `UserPreferences`/`RuntimePromptContext` 及全部辅助函数
|
||||
- 删除 runner 中 `runtime_client_time` 参数链路
|
||||
- 删除 `SystemAgentRuntimeConfig.extra_context`
|
||||
- 删除 `sections.py` 中 `env` section marker
|
||||
- 删除 `AgentPromptRegistry` 死代码
|
||||
- runner 中 `ai_language` 从 `user_context.settings.preferences` 提取传入prompt
|
||||
|
||||
### 安全规则
|
||||
|
||||
- `AGENTS.md` 添加 Git Safety 规则(禁止未经批准的破坏性git操作)
|
||||
- `.opencode/opencode.json` 添加高危git命令审批配置
|
||||
|
||||
### 测试
|
||||
|
||||
- 新增 22 个六爻算法单元测试
|
||||
- 重写 7 个 prompt 测试适配新签名
|
||||
- 全部 85 个单元测试通过
|
||||
|
||||
**修改文件 (14)**:
|
||||
- `backend/src/core/divination/derivation.py`
|
||||
- `backend/src/schemas/domain/divination.py`
|
||||
- `apps/lib/features/divination/data/models/divination_backend_models.dart`
|
||||
- `backend/src/core/agentscope/prompts/system_prompt.py`
|
||||
- `backend/src/core/agentscope/prompts/agent_prompt.py`
|
||||
- `backend/src/core/agentscope/prompts/worker_rules.py` (新)
|
||||
- `backend/src/core/agentscope/prompts/sections.py`
|
||||
- `backend/src/core/agentscope/prompts/user_prompt.py`
|
||||
- `backend/src/core/agentscope/runtime/runner.py`
|
||||
- `backend/tests/unit/test_agentscope_prompts.py`
|
||||
- `backend/tests/unit/test_divination_derivation.py` (新)
|
||||
- `docs/plans/liuyao-algorithm-audit.md`
|
||||
- `AGENTS.md`
|
||||
- `.opencode/opencode.json`
|
||||
|
||||
|
||||
### Git Commits
|
||||
|
||||
| Hash | Message |
|
||||
|------|---------|
|
||||
| `9598d16` | (see git log) |
|
||||
| `be68681` | (see git log) |
|
||||
|
||||
### Testing
|
||||
|
||||
- [OK] (Add test results)
|
||||
|
||||
### Status
|
||||
|
||||
[OK] **Completed**
|
||||
|
||||
### Next Steps
|
||||
|
||||
- None - task complete
|
||||
|
||||
|
||||
## Session 8: Session deletion anonymization for iOS compliance
|
||||
|
||||
**Date**: 2026-04-15
|
||||
**Task**: Session deletion anonymization for iOS compliance
|
||||
|
||||
### Summary
|
||||
|
||||
Replace soft-delete with anonymize + hard-delete. Add anonymous_session_snapshots table for analytics. Remove points_ledger.biz_id FK constraint for snapshot-style reference.
|
||||
|
||||
### Main Changes
|
||||
|
||||
|
||||
|
||||
### Git Commits
|
||||
|
||||
| Hash | Message |
|
||||
|------|---------|
|
||||
| `c2b726e` | (see git log) |
|
||||
|
||||
### Testing
|
||||
|
||||
- [OK] (Add test results)
|
||||
|
||||
### Status
|
||||
|
||||
[OK] **Completed**
|
||||
|
||||
### Next Steps
|
||||
|
||||
- None - task complete
|
||||
|
||||
|
||||
## Session 9: 起卦教程首次访问追踪 + Agent时间上下文
|
||||
## Session 1: 新人初始礼包购买追踪功能 - 计划制定
|
||||
|
||||
**Date**: 2026-04-16
|
||||
**Task**: 起卦教程首次访问追踪 + Agent时间上下文
|
||||
**Task**: starter-package-purchase-tracking
|
||||
|
||||
### Summary
|
||||
|
||||
实现了起卦教程首次访问追踪功能,包括后端 ProfileSettings 添加 DivinationTutorialSettings 字段、前端三处起卦页面添加首次访问检测和弹窗提示、使用本地状态管理避免并发覆盖问题、Agent系统提示添加时间上下文信息。
|
||||
在独立 worktree 中创建新人初始礼包购买追踪功能的实现计划。
|
||||
|
||||
### Main Changes
|
||||
### Background
|
||||
|
||||
用户需求:追踪用户是否已购买新人初始礼包($0.99/60积分),以便前端支付页面决定是否展示该礼包。
|
||||
|
||||
### Analysis
|
||||
|
||||
### Git Commits
|
||||
1. **现有数据结构分析**
|
||||
- `register_bonus_claims` 表已存在,用于注册送分去重
|
||||
- 表结构包含:`email_hash`(唯一)、`user_email_snapshot`、`first_user_id_snapshot`、`balance_snapshot`、`grant_event_id`
|
||||
- 已支持删除账号后重注册恢复余额的功能
|
||||
|
||||
| Hash | Message |
|
||||
2. **设计方案**
|
||||
- 在 `register_bonus_claims` 添加 `has_purchased_starter_pack` 字段
|
||||
- 利用现有 `email_hash` 唯一约束保证同一邮箱只能购买一次
|
||||
- 后端提供 `/api/v1/points/starter-pack/eligibility` API
|
||||
- 前端根据 API 返回决定是否展示礼包
|
||||
|
||||
3. **参考现有实现**
|
||||
- 已有计划文档:`docs/plans/ios-new-user-pack-payment-plan.md`
|
||||
- 完整支付计划包含:支付订单表、支付事件审计表、验单流程等
|
||||
- 本期仅实现资格查询,不涉及支付流程
|
||||
|
||||
### Implementation Plan
|
||||
|
||||
#### Phase 1: 数据库层
|
||||
- 创建 Alembic 迁移:`20260416_0001_add_starter_pack_tracking.py`
|
||||
- 添加字段:`has_purchased_starter_pack BOOLEAN NOT NULL DEFAULT FALSE`
|
||||
- 更新 Model:`backend/src/models/register_bonus_claims.py`
|
||||
|
||||
#### Phase 2: 后端 API
|
||||
- Schema:定义 `StarterPackEligibilityResponse`、`StarterPackInfo`
|
||||
- Repository:添加 `has_purchased_starter_pack()` 方法
|
||||
- Service:实现 `check_starter_pack_eligibility()` 逻辑
|
||||
- Router:添加 `GET /api/v1/points/starter-pack/eligibility` 路由
|
||||
|
||||
#### Phase 3: 前端集成
|
||||
- 创建 API 调用层
|
||||
- 更新支付页面 UI 展示逻辑
|
||||
|
||||
#### Phase 4: 文档与测试
|
||||
- 更新协议文档
|
||||
- 编写单元测试和集成测试
|
||||
|
||||
### Key Decisions
|
||||
|
||||
1. **字段命名**:`has_purchased_starter_pack`(布尔型),简洁明确
|
||||
2. **API 设计**:仅返回资格状态和礼包信息,不涉及支付逻辑
|
||||
3. **本期限制**:
|
||||
- 不实现支付流程
|
||||
- 不实现支付验证
|
||||
- `has_purchased_starter_pack` 字段暂时只读
|
||||
|
||||
### Files Created
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `69b34bd` | (see git log) |
|
||||
|
||||
### Testing
|
||||
|
||||
- [OK] (Add test results)
|
||||
|
||||
### Status
|
||||
|
||||
[OK] **Completed**
|
||||
| `.trellis/tasks/04-16-starter-package-purchase-tracking/task.json` | 任务配置 |
|
||||
| `.trellis/tasks/04-16-starter-package-purchase-tracking/prd.md` | 需求与实现计划 |
|
||||
|
||||
### Next Steps
|
||||
|
||||
- None - task complete
|
||||
1. 实现 Phase 1:数据库迁移和 Model 更新
|
||||
2. 实现 Phase 2:后端 API
|
||||
3. 实现 Phase 3:前端集成
|
||||
4. 实现 Phase 4:文档与测试
|
||||
|
||||
### Status
|
||||
|
||||
# **In Progress** - 计划已制定,等待实现
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../models/package_info.dart';
|
||||
|
||||
class PointsApi {
|
||||
const PointsApi(this._dio);
|
||||
|
||||
final Dio _dio;
|
||||
|
||||
Future<PackagesResult> getPackages() async {
|
||||
final response = await _dio.get('/api/v1/points/packages');
|
||||
return PackagesResult.fromJson(response.data as Map<String, dynamic>);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
enum ProductCode { newUserPack, basicPack, popularPack, premiumPack }
|
||||
|
||||
enum PackageType { starter, regular }
|
||||
|
||||
class PackageInfo {
|
||||
const PackageInfo({
|
||||
required this.productCode,
|
||||
required this.type,
|
||||
required this.price,
|
||||
required this.credits,
|
||||
required this.isStarter,
|
||||
required this.starterEligible,
|
||||
required this.sortOrder,
|
||||
});
|
||||
|
||||
final ProductCode productCode;
|
||||
final PackageType type;
|
||||
final double price;
|
||||
final int credits;
|
||||
final bool isStarter;
|
||||
final bool starterEligible;
|
||||
final int sortOrder;
|
||||
|
||||
factory PackageInfo.fromJson(Map<String, dynamic> json) {
|
||||
return PackageInfo(
|
||||
productCode: _parseProductCode(json['productCode'] as String),
|
||||
type: json['type'] == 'starter'
|
||||
? PackageType.starter
|
||||
: PackageType.regular,
|
||||
price: (json['price'] as num).toDouble(),
|
||||
credits: json['credits'] as int,
|
||||
isStarter: json['isStarter'] as bool,
|
||||
starterEligible: json['starterEligible'] as bool,
|
||||
sortOrder: json['sortOrder'] as int,
|
||||
);
|
||||
}
|
||||
|
||||
static ProductCode _parseProductCode(String code) {
|
||||
return switch (code) {
|
||||
'new_user_pack' => ProductCode.newUserPack,
|
||||
'basic_pack' => ProductCode.basicPack,
|
||||
'popular_pack' => ProductCode.popularPack,
|
||||
'premium_pack' => ProductCode.premiumPack,
|
||||
_ => throw ArgumentError('Unknown product code: $code'),
|
||||
};
|
||||
}
|
||||
|
||||
String get priceDisplay => '\$${price.toStringAsFixed(2)}';
|
||||
}
|
||||
|
||||
class PackagesResult {
|
||||
const PackagesResult({
|
||||
required this.region,
|
||||
required this.currency,
|
||||
required this.packages,
|
||||
});
|
||||
|
||||
final String region;
|
||||
final String currency;
|
||||
final List<PackageInfo> packages;
|
||||
|
||||
factory PackagesResult.fromJson(Map<String, dynamic> json) {
|
||||
return PackagesResult(
|
||||
region: json['region'] as String,
|
||||
currency: json['currency'] as String,
|
||||
packages: (json['packages'] as List<dynamic>)
|
||||
.map((e) => PackageInfo.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -111,7 +111,7 @@ class ProfileApi {
|
||||
aiLanguage: (preferencesRaw['ai_language'] as String?) ?? 'zh-CN',
|
||||
timezone:
|
||||
(preferencesRaw['timezone'] as String?) ?? 'Asia/Shanghai',
|
||||
country: (preferencesRaw['country'] as String?) ?? 'CN',
|
||||
country: (preferencesRaw['country'] as String?) ?? 'US',
|
||||
)
|
||||
: const PreferenceSettings();
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ class PreferenceSettings {
|
||||
this.interfaceLanguage = 'zh-CN',
|
||||
this.aiLanguage = 'zh-CN',
|
||||
this.timezone = 'Asia/Shanghai',
|
||||
this.country = 'CN',
|
||||
this.country = 'US',
|
||||
});
|
||||
|
||||
final String interfaceLanguage;
|
||||
|
||||
@@ -1,15 +1,66 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../../app/di/injection.dart';
|
||||
import '../../../../core/auth/session_store.dart';
|
||||
import '../../../../core/logging/logger.dart';
|
||||
import '../../../../data/network/api_client.dart';
|
||||
import '../../../../data/storage/local_kv_store.dart';
|
||||
import '../../../../l10n/app_localizations.dart';
|
||||
import '../../../../shared/theme/app_color_palette.dart';
|
||||
import '../../../../shared/theme/design_tokens.dart';
|
||||
import '../../../points/data/apis/points_api.dart';
|
||||
import '../../../points/data/models/package_info.dart';
|
||||
import '../widgets/settings_section_widgets.dart';
|
||||
|
||||
class CoinCenterScreen extends StatelessWidget {
|
||||
class CoinCenterScreen extends StatefulWidget {
|
||||
const CoinCenterScreen({super.key, required this.balance});
|
||||
|
||||
final int balance;
|
||||
|
||||
@override
|
||||
State<CoinCenterScreen> createState() => _CoinCenterScreenState();
|
||||
}
|
||||
|
||||
class _CoinCenterScreenState extends State<CoinCenterScreen> {
|
||||
final Logger _logger = getLogger('features.settings.coin_center_screen');
|
||||
List<PackageInfo>? _packages;
|
||||
bool _isLoading = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadPackages();
|
||||
}
|
||||
|
||||
Future<void> _loadPackages() async {
|
||||
try {
|
||||
final sessionStore = SessionStore(LocalKvStore());
|
||||
final apiClient = ApiClient(
|
||||
baseUrl: appDependencies.backendUrl,
|
||||
tokenProvider: sessionStore.getToken,
|
||||
);
|
||||
final api = PointsApi(apiClient.rawDio);
|
||||
final result = await api.getPackages();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_packages = result.packages;
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
} catch (e, stackTrace) {
|
||||
_logger.error(
|
||||
message: 'Failed to load packages',
|
||||
error: e,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
@@ -52,7 +103,7 @@ class CoinCenterScreen extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: AppSpacing.xs),
|
||||
Text(
|
||||
l10n.settingsCoinBalanceValue(balance),
|
||||
l10n.settingsCoinBalanceValue(widget.balance),
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.headlineMedium?.copyWith(color: colors.onPrimary),
|
||||
@@ -69,26 +120,55 @@ class CoinCenterScreen extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: AppSpacing.xl),
|
||||
SectionLabel(text: l10n.settingsCoinRechargeSection),
|
||||
CoinPackageCard(
|
||||
title: l10n.settingsCoinPackBasic,
|
||||
price: '\$4.99',
|
||||
amount: 100,
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
CoinPackageCard(
|
||||
title: l10n.settingsCoinPackPopular,
|
||||
price: '\$7.99',
|
||||
amount: 210,
|
||||
badge: l10n.settingsCoinPackPopularBadge,
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
CoinPackageCard(
|
||||
title: l10n.settingsCoinPackPremium,
|
||||
price: '\$12.99',
|
||||
amount: 415,
|
||||
),
|
||||
..._buildPackageCards(l10n),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _buildPackageCards(AppLocalizations l10n) {
|
||||
if (_isLoading) {
|
||||
return [
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(AppSpacing.xl),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
if (_packages == null || _packages!.isEmpty) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return List.generate(_packages!.length, (index) {
|
||||
final pkg = _packages![index];
|
||||
return Column(
|
||||
children: [
|
||||
if (index > 0) const SizedBox(height: AppSpacing.md),
|
||||
CoinPackageCard(
|
||||
title: _getPackageTitle(pkg, l10n),
|
||||
price: pkg.priceDisplay,
|
||||
amount: pkg.credits,
|
||||
badge: _getPackageBadge(pkg, l10n),
|
||||
),
|
||||
],
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
String? _getPackageBadge(PackageInfo pkg, AppLocalizations l10n) {
|
||||
if (pkg.productCode == ProductCode.popularPack) {
|
||||
return l10n.settingsCoinPackPopularBadge;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String _getPackageTitle(PackageInfo pkg, AppLocalizations l10n) {
|
||||
return switch (pkg.productCode) {
|
||||
ProductCode.newUserPack => l10n.settingsCoinPackStarter,
|
||||
ProductCode.basicPack => l10n.settingsCoinPackBasic,
|
||||
ProductCode.popularPack => l10n.settingsCoinPackPopular,
|
||||
ProductCode.premiumPack => l10n.settingsCoinPackPremium,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,6 +190,7 @@
|
||||
},
|
||||
"settingsCoinCenterDescription": "",
|
||||
"settingsCoinRechargeSection": "Recharge Packages",
|
||||
"settingsCoinPackStarter": "New User Pack",
|
||||
"settingsCoinPackBasic": "Starter Pack",
|
||||
"settingsCoinPackPopular": "Popular Pack",
|
||||
"settingsCoinPackPremium": "Premium Pack",
|
||||
|
||||
@@ -939,6 +939,12 @@ abstract class AppLocalizations {
|
||||
/// **'充值套餐'**
|
||||
String get settingsCoinRechargeSection;
|
||||
|
||||
/// No description provided for @settingsCoinPackStarter.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'新人专享包'**
|
||||
String get settingsCoinPackStarter;
|
||||
|
||||
/// No description provided for @settingsCoinPackBasic.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
|
||||
@@ -469,6 +469,9 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get settingsCoinRechargeSection => 'Recharge Packages';
|
||||
|
||||
@override
|
||||
String get settingsCoinPackStarter => 'New User Pack';
|
||||
|
||||
@override
|
||||
String get settingsCoinPackBasic => 'Starter Pack';
|
||||
|
||||
|
||||
@@ -454,6 +454,9 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
@override
|
||||
String get settingsCoinRechargeSection => '充值套餐';
|
||||
|
||||
@override
|
||||
String get settingsCoinPackStarter => '新人专享包';
|
||||
|
||||
@override
|
||||
String get settingsCoinPackBasic => '入门补充包';
|
||||
|
||||
@@ -1460,6 +1463,9 @@ class AppLocalizationsZhHant extends AppLocalizationsZh {
|
||||
@override
|
||||
String get settingsCoinRechargeSection => '儲值套餐';
|
||||
|
||||
@override
|
||||
String get settingsCoinPackStarter => '新人專享包';
|
||||
|
||||
@override
|
||||
String get settingsCoinPackBasic => '入門補充包';
|
||||
|
||||
|
||||
@@ -190,6 +190,7 @@
|
||||
},
|
||||
"settingsCoinCenterDescription": "",
|
||||
"settingsCoinRechargeSection": "充值套餐",
|
||||
"settingsCoinPackStarter": "新人专享包",
|
||||
"settingsCoinPackBasic": "入门补充包",
|
||||
"settingsCoinPackPopular": "常用加量包",
|
||||
"settingsCoinPackPremium": "高频进阶包",
|
||||
|
||||
@@ -123,6 +123,7 @@
|
||||
},
|
||||
"settingsCoinCenterDescription": "",
|
||||
"settingsCoinRechargeSection": "儲值套餐",
|
||||
"settingsCoinPackStarter": "新人專享包",
|
||||
"settingsCoinPackBasic": "入門補充包",
|
||||
"settingsCoinPackPopular": "常用加量包",
|
||||
"settingsCoinPackPremium": "高頻進階包",
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
"""add has_purchased_starter_pack to register_bonus_claims
|
||||
|
||||
Revision ID: 20260416_0001
|
||||
Revises: 20260413_0004
|
||||
Create Date: 2026-04-16 12:00:00
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = "20260416_0001"
|
||||
down_revision: Union[str, Sequence[str], None] = "20260415_0002"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"register_bonus_claims",
|
||||
sa.Column(
|
||||
"has_purchased_starter_pack",
|
||||
sa.Boolean(),
|
||||
nullable=False,
|
||||
server_default=sa.text("false"),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("register_bonus_claims", "has_purchased_starter_pack")
|
||||
@@ -15,6 +15,7 @@ from core.logging import get_logger
|
||||
from models.llm import Llm
|
||||
from models.llm_factory import LlmFactory
|
||||
from models.system_agents import SystemAgents
|
||||
from utils.paths import get_llm_catalog_config_path, get_system_agents_config_path
|
||||
|
||||
logger = get_logger("core.config.initial.init_data")
|
||||
|
||||
@@ -48,9 +49,7 @@ class SystemAgentsYaml(BaseModel):
|
||||
|
||||
|
||||
def _default_catalog_path() -> Path:
|
||||
return (
|
||||
Path(__file__).resolve().parents[1] / "static" / "database" / "llm_catalog.yaml"
|
||||
)
|
||||
return get_llm_catalog_config_path()
|
||||
|
||||
|
||||
def load_llm_catalog(catalog_path: Path | None = None) -> dict[str, Any]:
|
||||
@@ -77,12 +76,7 @@ def load_llm_catalog(catalog_path: Path | None = None) -> dict[str, Any]:
|
||||
|
||||
|
||||
def _default_system_agents_path() -> Path:
|
||||
return (
|
||||
Path(__file__).resolve().parents[1]
|
||||
/ "static"
|
||||
/ "database"
|
||||
/ "system_agents.yaml"
|
||||
)
|
||||
return get_system_agents_config_path()
|
||||
|
||||
|
||||
def load_system_agents(catalog_path: Path | None = None) -> dict[str, Any]:
|
||||
|
||||
@@ -24,6 +24,7 @@ from core.config.notification.static_schema import (
|
||||
from models.auth_user import AuthUser
|
||||
from models.notification import Notification
|
||||
from models.user_notification import UserNotification
|
||||
from utils.paths import get_notification_config_dir
|
||||
|
||||
logger = get_logger("core.config.notification.static_sync")
|
||||
|
||||
@@ -41,12 +42,7 @@ class StaticNotificationSyncResult:
|
||||
|
||||
|
||||
def default_static_notification_path() -> Path:
|
||||
return (
|
||||
Path(__file__).resolve().parents[1]
|
||||
/ "static"
|
||||
/ "notification"
|
||||
/ "notifications"
|
||||
)
|
||||
return get_notification_config_dir()
|
||||
|
||||
|
||||
def load_static_notification_documents(
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
from core.config.packages.registry import (
|
||||
clear_packages_cache,
|
||||
get_packages_config_for_region,
|
||||
)
|
||||
from core.config.packages.schema import (
|
||||
PackageConfig,
|
||||
PackageType,
|
||||
ProductCode,
|
||||
RegionPackagesConfig,
|
||||
load_packages_config,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"clear_packages_cache",
|
||||
"get_packages_config_for_region",
|
||||
"load_packages_config",
|
||||
"PackageConfig",
|
||||
"PackageType",
|
||||
"ProductCode",
|
||||
"RegionPackagesConfig",
|
||||
]
|
||||
@@ -0,0 +1,34 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from core.config.packages.schema import (
|
||||
RegionPackagesConfig,
|
||||
load_packages_config,
|
||||
)
|
||||
from utils.paths import get_default_package_config_path, get_package_config_path
|
||||
|
||||
|
||||
_CONFIG_CACHE: dict[str, RegionPackagesConfig] = {}
|
||||
|
||||
|
||||
def get_packages_config_for_region(country: str) -> RegionPackagesConfig:
|
||||
if country in _CONFIG_CACHE:
|
||||
return _CONFIG_CACHE[country]
|
||||
|
||||
region_file = get_package_config_path(country)
|
||||
if region_file.exists():
|
||||
config = load_packages_config(region_file)
|
||||
_CONFIG_CACHE[country] = config
|
||||
return config
|
||||
|
||||
default_file = get_default_package_config_path()
|
||||
if not default_file.exists():
|
||||
raise RuntimeError(f"No default packages config found: {default_file}")
|
||||
|
||||
config = load_packages_config(default_file)
|
||||
_CONFIG_CACHE[country] = config
|
||||
return config
|
||||
|
||||
|
||||
def clear_packages_cache() -> None:
|
||||
global _CONFIG_CACHE
|
||||
_CONFIG_CACHE = {}
|
||||
@@ -0,0 +1,51 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import ClassVar, Literal
|
||||
|
||||
import yaml
|
||||
from pydantic import BaseModel, ConfigDict, Field, ValidationError
|
||||
|
||||
|
||||
class PackageType(str, Enum):
|
||||
STARTER = "starter"
|
||||
REGULAR = "regular"
|
||||
|
||||
|
||||
ProductCode = Literal[
|
||||
"new_user_pack",
|
||||
"basic_pack",
|
||||
"popular_pack",
|
||||
"premium_pack",
|
||||
]
|
||||
|
||||
|
||||
class PackageConfig(BaseModel):
|
||||
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
|
||||
|
||||
product_code: ProductCode
|
||||
type: PackageType
|
||||
price: float = Field(ge=0)
|
||||
credits: int = Field(ge=1)
|
||||
sort_order: int = Field(default=0, ge=0)
|
||||
enabled: bool = Field(default=True)
|
||||
|
||||
|
||||
class RegionPackagesConfig(BaseModel):
|
||||
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
|
||||
|
||||
region: str = Field(min_length=1, max_length=8)
|
||||
currency: str = Field(min_length=1, max_length=8)
|
||||
packages: list[PackageConfig] = Field(min_length=1)
|
||||
|
||||
|
||||
def load_packages_config(path: Path) -> RegionPackagesConfig:
|
||||
with path.open("r", encoding="utf-8") as file:
|
||||
loaded: object = yaml.safe_load(file) or {}
|
||||
if not isinstance(loaded, dict):
|
||||
raise ValueError(f"Invalid packages config format: {path}")
|
||||
try:
|
||||
return RegionPackagesConfig.model_validate(loaded)
|
||||
except ValidationError as exc:
|
||||
raise ValueError(f"Invalid packages config data: {path}") from exc
|
||||
@@ -0,0 +1,30 @@
|
||||
region: DEFAULT
|
||||
currency: USD
|
||||
packages:
|
||||
- product_code: new_user_pack
|
||||
type: starter
|
||||
price: 0.99
|
||||
credits: 60
|
||||
sort_order: 0
|
||||
enabled: true
|
||||
|
||||
- product_code: basic_pack
|
||||
type: regular
|
||||
price: 4.99
|
||||
credits: 100
|
||||
sort_order: 10
|
||||
enabled: true
|
||||
|
||||
- product_code: popular_pack
|
||||
type: regular
|
||||
price: 7.99
|
||||
credits: 210
|
||||
sort_order: 20
|
||||
enabled: true
|
||||
|
||||
- product_code: premium_pack
|
||||
type: regular
|
||||
price: 12.99
|
||||
credits: 415
|
||||
sort_order: 30
|
||||
enabled: true
|
||||
@@ -0,0 +1,30 @@
|
||||
region: US
|
||||
currency: USD
|
||||
packages:
|
||||
- product_code: new_user_pack
|
||||
type: starter
|
||||
price: 0.99
|
||||
credits: 60
|
||||
sort_order: 0
|
||||
enabled: true
|
||||
|
||||
- product_code: basic_pack
|
||||
type: regular
|
||||
price: 4.99
|
||||
credits: 100
|
||||
sort_order: 10
|
||||
enabled: true
|
||||
|
||||
- product_code: popular_pack
|
||||
type: regular
|
||||
price: 7.99
|
||||
credits: 210
|
||||
sort_order: 20
|
||||
enabled: true
|
||||
|
||||
- product_code: premium_pack
|
||||
type: regular
|
||||
price: 12.99
|
||||
credits: 415
|
||||
sort_order: 30
|
||||
enabled: true
|
||||
@@ -5,6 +5,8 @@ from functools import lru_cache
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from utils.paths import get_gua_catalog_path
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GuaCatalogItem:
|
||||
@@ -24,8 +26,7 @@ class GuaCatalogItem:
|
||||
|
||||
|
||||
def _resolve_catalog_file() -> Path:
|
||||
current = Path(__file__).resolve()
|
||||
target = current.parent / "data/gua_catalog.json"
|
||||
target = get_gua_catalog_path()
|
||||
if not target.exists():
|
||||
raise FileNotFoundError(f"gua_catalog.json not found: {target}")
|
||||
return target
|
||||
|
||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import BigInteger, String, Text, UniqueConstraint
|
||||
from sqlalchemy import BigInteger, Boolean, String, Text, UniqueConstraint
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
@@ -30,3 +30,6 @@ class RegisterBonusClaims(TimestampMixin, Base):
|
||||
)
|
||||
balance_snapshot: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||
grant_event_id: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
has_purchased_starter_pack: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, default=False
|
||||
)
|
||||
|
||||
@@ -14,7 +14,7 @@ class PreferenceSettings(BaseModel):
|
||||
interface_language: str = "zh-CN"
|
||||
ai_language: str = "zh-CN"
|
||||
timezone: str = "Asia/Shanghai"
|
||||
country: str = "CN"
|
||||
country: str = "US"
|
||||
|
||||
@field_validator("interface_language", "ai_language")
|
||||
@classmethod
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
from utils.paths import (
|
||||
get_config_root,
|
||||
get_database_config_dir,
|
||||
get_default_package_config_path,
|
||||
get_divination_data_dir,
|
||||
get_gua_catalog_path,
|
||||
get_llm_catalog_config_path,
|
||||
get_notification_config_dir,
|
||||
get_package_config_path,
|
||||
get_packages_config_dir,
|
||||
get_src_root,
|
||||
get_static_config_dir,
|
||||
get_system_agents_config_path,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"get_config_root",
|
||||
"get_database_config_dir",
|
||||
"get_default_package_config_path",
|
||||
"get_divination_data_dir",
|
||||
"get_gua_catalog_path",
|
||||
"get_llm_catalog_config_path",
|
||||
"get_notification_config_dir",
|
||||
"get_package_config_path",
|
||||
"get_packages_config_dir",
|
||||
"get_src_root",
|
||||
"get_static_config_dir",
|
||||
"get_system_agents_config_path",
|
||||
]
|
||||
@@ -0,0 +1,51 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def get_src_root() -> Path:
|
||||
return Path(__file__).parent.parent
|
||||
|
||||
|
||||
def get_config_root() -> Path:
|
||||
return get_src_root() / "core/config"
|
||||
|
||||
|
||||
def get_static_config_dir() -> Path:
|
||||
return get_config_root() / "static"
|
||||
|
||||
|
||||
def get_packages_config_dir() -> Path:
|
||||
return get_static_config_dir() / "packages"
|
||||
|
||||
|
||||
def get_database_config_dir() -> Path:
|
||||
return get_static_config_dir() / "database"
|
||||
|
||||
|
||||
def get_notification_config_dir() -> Path:
|
||||
return get_static_config_dir() / "notification/notifications"
|
||||
|
||||
|
||||
def get_divination_data_dir() -> Path:
|
||||
return get_src_root() / "core/divination/data"
|
||||
|
||||
|
||||
def get_package_config_path(country: str) -> Path:
|
||||
return get_packages_config_dir() / f"{country.lower()}.yaml"
|
||||
|
||||
|
||||
def get_default_package_config_path() -> Path:
|
||||
return get_packages_config_dir() / "default.yaml"
|
||||
|
||||
|
||||
def get_llm_catalog_config_path() -> Path:
|
||||
return get_database_config_dir() / "llm_catalog.yaml"
|
||||
|
||||
|
||||
def get_system_agents_config_path() -> Path:
|
||||
return get_database_config_dir() / "system_agents.yaml"
|
||||
|
||||
|
||||
def get_gua_catalog_path() -> Path:
|
||||
return get_divination_data_dir() / "gua_catalog.json"
|
||||
@@ -16,17 +16,11 @@ from schemas.agent.runtime_config import (
|
||||
MessageContextConfig,
|
||||
RuntimeConfig,
|
||||
)
|
||||
from utils.paths import get_system_agents_config_path
|
||||
|
||||
|
||||
def _default_system_agents_path() -> Path:
|
||||
return (
|
||||
Path(__file__).resolve().parents[2]
|
||||
/ "core"
|
||||
/ "config"
|
||||
/ "static"
|
||||
/ "database"
|
||||
/ "system_agents.yaml"
|
||||
)
|
||||
return get_system_agents_config_path()
|
||||
|
||||
|
||||
def _load_system_agents_yaml(path: Path | None = None) -> dict[str, object]:
|
||||
|
||||
@@ -10,6 +10,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from models.agent_chat_message import AgentChatMessage
|
||||
from models.points_audit_ledger import PointsAuditLedger
|
||||
from models.points_ledger import PointsLedger
|
||||
from models.profile import Profile
|
||||
from models.register_bonus_claims import RegisterBonusClaims
|
||||
from models.user_points import UserPoints
|
||||
from schemas.domain.points import (
|
||||
@@ -189,3 +190,22 @@ class PointsRepository:
|
||||
claim.balance_snapshot = int(balance_snapshot)
|
||||
await self._session.flush()
|
||||
return True
|
||||
|
||||
async def has_purchased_starter_pack(
|
||||
self,
|
||||
*,
|
||||
email_hash: str,
|
||||
) -> bool:
|
||||
claim = await self.get_register_bonus_claim(email_hash=email_hash)
|
||||
if claim is None:
|
||||
return False
|
||||
return bool(claim.has_purchased_starter_pack)
|
||||
|
||||
async def get_profile_settings(
|
||||
self,
|
||||
*,
|
||||
user_id: UUID,
|
||||
) -> dict[str, object] | None:
|
||||
stmt = select(Profile.settings).where(Profile.id == user_id).limit(1)
|
||||
row = (await self._session.execute(stmt)).scalar_one_or_none()
|
||||
return row
|
||||
|
||||
@@ -6,7 +6,7 @@ from fastapi import APIRouter, Depends
|
||||
|
||||
from core.auth.models import CurrentUser
|
||||
from v1.points.dependencies import get_points_service
|
||||
from v1.points.schemas import PointsBalanceResponse
|
||||
from v1.points.schemas import PackagesResponse, PackageInfo, PointsBalanceResponse
|
||||
from v1.points.service import PointsService
|
||||
from v1.users.dependencies import get_current_user
|
||||
|
||||
@@ -26,3 +26,31 @@ async def get_points_balance(
|
||||
runCost=result.run_cost,
|
||||
canRun=result.can_run,
|
||||
)
|
||||
|
||||
|
||||
@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:
|
||||
result = await service.get_available_packages(
|
||||
user_id=current_user.id,
|
||||
user_email=current_user.email or "",
|
||||
)
|
||||
|
||||
return PackagesResponse(
|
||||
region=result.region,
|
||||
currency=result.currency,
|
||||
packages=[
|
||||
PackageInfo(
|
||||
productCode=pkg.product_code,
|
||||
type=pkg.type.value,
|
||||
price=pkg.price,
|
||||
credits=pkg.credits,
|
||||
isStarter=pkg.is_starter,
|
||||
starterEligible=pkg.starter_eligible,
|
||||
sortOrder=pkg.sort_order,
|
||||
)
|
||||
for pkg in result.packages
|
||||
],
|
||||
)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
@@ -11,3 +13,23 @@ class PointsBalanceResponse(BaseModel):
|
||||
available_balance: int = Field(alias="availableBalance", ge=0)
|
||||
run_cost: int = Field(alias="runCost", gt=0)
|
||||
can_run: bool = Field(alias="canRun")
|
||||
|
||||
|
||||
class PackageInfo(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True, serialize_by_alias=True)
|
||||
|
||||
product_code: str = Field(alias="productCode", min_length=1, max_length=128)
|
||||
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")
|
||||
sort_order: int = Field(alias="sortOrder", ge=0)
|
||||
|
||||
|
||||
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]
|
||||
|
||||
@@ -4,9 +4,13 @@ from dataclasses import dataclass
|
||||
from decimal import Decimal
|
||||
import hashlib
|
||||
import hmac
|
||||
from typing import Literal
|
||||
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 (
|
||||
@@ -18,8 +22,12 @@ 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.points.repository import PointsRepository
|
||||
|
||||
if TYPE_CHECKING:
|
||||
pass
|
||||
|
||||
RUN_POINTS_COST = 20
|
||||
|
||||
|
||||
@@ -55,6 +63,24 @@ class RegisterBonusResult:
|
||||
event_id: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PackageInfoResult:
|
||||
product_code: str
|
||||
type: PackageType
|
||||
price: float
|
||||
credits: int
|
||||
sort_order: int
|
||||
is_starter: bool
|
||||
starter_eligible: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PackagesResult:
|
||||
region: str
|
||||
currency: str
|
||||
packages: list[PackageInfoResult]
|
||||
|
||||
|
||||
class PointsService:
|
||||
def __init__(self, repository: PointsRepository) -> None:
|
||||
self._repository = repository
|
||||
@@ -408,3 +434,50 @@ class PointsService:
|
||||
digestmod=hashlib.sha256,
|
||||
)
|
||||
return digest.hexdigest()
|
||||
|
||||
async def get_available_packages(
|
||||
self,
|
||||
*,
|
||||
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
|
||||
if normalized_email:
|
||||
email_hash = self._build_register_bonus_email_hash(normalized_email)
|
||||
has_starter = await self._repository.has_purchased_starter_pack(
|
||||
email_hash=email_hash
|
||||
)
|
||||
|
||||
packages: list[PackageInfoResult] = []
|
||||
for pkg in pkg_config.packages:
|
||||
if not pkg.enabled:
|
||||
continue
|
||||
if pkg.type == PackageType.STARTER and has_starter:
|
||||
continue
|
||||
|
||||
packages.append(
|
||||
PackageInfoResult(
|
||||
product_code=pkg.product_code,
|
||||
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
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
return PackagesResult(
|
||||
region=pkg_config.region,
|
||||
currency=pkg_config.currency,
|
||||
packages=sorted(packages, key=lambda p: p.sort_order),
|
||||
)
|
||||
|
||||
@@ -1,247 +0,0 @@
|
||||
# iOS 新人包支付接入与一次性权益计划
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
当前前端充值页为静态套餐展示,购买按钮未接入真实支付链路。现需新增 iOS 新人包:
|
||||
|
||||
- 价格:`$0.99`
|
||||
- 积分:`60`
|
||||
- 资格:同邮箱只能购买一次
|
||||
- 删除账号后同邮箱重新注册,不刷新新人包资格
|
||||
|
||||
同时补齐后端真实支付路由与订单审计能力,前端不再硬编码套餐。
|
||||
|
||||
## 2. 本次范围
|
||||
|
||||
### 2.1 In Scope
|
||||
|
||||
1. 后端新增 iOS 支付相关路由(下单/验单/查询/回调)。
|
||||
2. 新建支付订单主表与支付事件审计表。
|
||||
3. 改造 `register_bonus_claims` 为可承载“权益唯一占用”能力。
|
||||
4. 前端套餐由后端接口驱动,不再硬编码三档固定套餐。
|
||||
5. 新人包资格前后端联动(展示、购买、验单、入账)。
|
||||
|
||||
### 2.2 Out of Scope
|
||||
|
||||
1. Android 支付渠道接入。
|
||||
2. Apple 开发者账号正式联调(当前账号未就绪)。
|
||||
3. 财务对账后台页面。
|
||||
|
||||
## 3. 数据模型设计
|
||||
|
||||
## 3.1 新建表:`payment_orders`
|
||||
|
||||
用途:订单当前态,支持幂等验单与退款状态跟踪。
|
||||
|
||||
建议字段:
|
||||
|
||||
- `id` UUID PK
|
||||
- `order_no` VARCHAR(64) UNIQUE
|
||||
- `user_id` UUID NOT NULL (`auth.users.id`)
|
||||
- `channel` VARCHAR(16) NOT NULL (`ios_iap`)
|
||||
- `product_code` VARCHAR(64) NOT NULL(例:`new_user_pack_099_60`)
|
||||
- `price_usd` NUMERIC(12,6) NOT NULL
|
||||
- `credits` BIGINT NOT NULL
|
||||
- `currency` VARCHAR(8) NOT NULL DEFAULT `USD`
|
||||
- `status` VARCHAR(24) NOT NULL
|
||||
- `created|receipt_submitted|verified|credited|refund_pending|refunded|revoked|failed`
|
||||
- `apple_transaction_id` VARCHAR(128) NULL UNIQUE
|
||||
- `apple_original_transaction_id` VARCHAR(128) NULL
|
||||
- `app_account_token` UUID NULL
|
||||
- `idempotency_key` VARCHAR(128) NULL UNIQUE
|
||||
- `error_code` VARCHAR(64) NULL
|
||||
- `error_message` TEXT NULL
|
||||
- `created_at` / `updated_at`
|
||||
|
||||
关键约束:
|
||||
|
||||
- `credits > 0`
|
||||
- `price_usd >= 0`
|
||||
- `status` check
|
||||
- `channel='ios_iap'`(本期)
|
||||
|
||||
## 3.2 新建表:`payment_order_events`
|
||||
|
||||
用途:支付事件不可变审计流水(验单结果、回调、退款、冲正)。
|
||||
|
||||
建议字段:
|
||||
|
||||
- `id` UUID PK
|
||||
- `order_id` UUID NOT NULL FK `payment_orders.id`
|
||||
- `event_type` VARCHAR(32) NOT NULL
|
||||
- `order_created|receipt_submitted|verify_success|verify_failed|credited|refund_notified|refunded|revoke_notified|reversed`
|
||||
- `event_source` VARCHAR(24) NOT NULL
|
||||
- `api|apple_server_notification|job`
|
||||
- `event_idempotency_key` VARCHAR(128) NULL UNIQUE
|
||||
- `payload` JSONB NOT NULL
|
||||
- `operator_id` UUID NULL
|
||||
- `created_at`
|
||||
|
||||
## 3.3 改造表:`register_bonus_claims`
|
||||
|
||||
目标:从“注册送分去重”升级为“权益唯一占用”。
|
||||
|
||||
新增字段建议:
|
||||
|
||||
- `offer_code` VARCHAR(64) NOT NULL(例:`register_bonus_20`、`new_user_pack_099_60`)
|
||||
- `claim_source` VARCHAR(24) NOT NULL(`register_bonus|ios_purchase`)
|
||||
- `claim_order_id` UUID NULL FK `payment_orders.id`
|
||||
|
||||
新增唯一约束:
|
||||
|
||||
- `UNIQUE(offer_code, email_hash)`
|
||||
|
||||
保留行为:
|
||||
|
||||
- `first_user_id` 允许 `ON DELETE SET NULL`,保证删号后资格仍占用。
|
||||
|
||||
## 4. 路由与服务边界
|
||||
|
||||
## 4.1 后端新增路由(v1)
|
||||
|
||||
1. `GET /api/v1/payments/packages`
|
||||
- 返回可购买套餐列表与用户资格(是否可买新人包)。
|
||||
2. `POST /api/v1/payments/orders`
|
||||
- 创建订单,返回 `orderNo` 与客户端支付所需参数。
|
||||
3. `POST /api/v1/payments/orders/{orderNo}/verify-ios-receipt`
|
||||
- 提交 iOS 收据,后端调用 Apple 校验。
|
||||
4. `GET /api/v1/payments/orders/{orderNo}`
|
||||
- 查询订单状态与入账结果。
|
||||
5. `POST /api/v1/payments/webhooks/apple`
|
||||
- 接收 App Store Server Notifications V2,处理退款/撤销。
|
||||
|
||||
## 4.2 分层职责
|
||||
|
||||
- Router:鉴权、请求校验、RFC7807 错误映射。
|
||||
- Service:
|
||||
- 资格判断(新人包是否可买)
|
||||
- 下单与验单业务编排
|
||||
- 入账积分与冲正
|
||||
- 幂等控制
|
||||
- Repository:
|
||||
- `payment_orders`/`payment_order_events`/`register_bonus_claims` 读写
|
||||
- 订单状态流转条件更新
|
||||
|
||||
## 5. 核心流程
|
||||
|
||||
## 5.1 下单与资格检查
|
||||
|
||||
```text
|
||||
客户端请求套餐 -> GET /payments/packages
|
||||
-> 后端按 email_hash 检查 offer_code='new_user_pack_099_60' 是否已占用
|
||||
-> 返回 eligible=true/false
|
||||
|
||||
客户端创建订单 -> POST /payments/orders
|
||||
-> 再次做资格校验(防并发)
|
||||
-> 创建 payment_orders(status=created)
|
||||
-> 写 payment_order_events(order_created)
|
||||
```
|
||||
|
||||
## 5.2 iOS 验单与积分入账
|
||||
|
||||
```text
|
||||
客户端支付后提交 receipt -> POST /orders/{orderNo}/verify-ios-receipt
|
||||
-> 后端调用 Apple 验单(可切 sandbox)
|
||||
-> 验证 transaction_id 幂等
|
||||
-> 状态 verified
|
||||
-> 原子事务:
|
||||
1) 占用权益 register_bonus_claims(offer_code,email_hash)
|
||||
2) 写 points_ledger(grant)
|
||||
3) 写 points_audit_ledger(direction=1,billed_to='user')
|
||||
4) 订单置 credited
|
||||
5) 写 payment_order_events(credited)
|
||||
```
|
||||
|
||||
## 5.3 退款与冲正
|
||||
|
||||
```text
|
||||
Apple 回调退款 -> POST /payments/webhooks/apple
|
||||
-> 定位 order(transaction_id / original_transaction_id)
|
||||
-> 幂等处理通知
|
||||
-> 状态 refunded/revoked
|
||||
-> 原子事务:
|
||||
1) 写 points_ledger(adjust/consume reverse)
|
||||
2) 写 points_audit_ledger(direction=-1,billed_to='platform',metadata.reason='refund')
|
||||
3) 写 payment_order_events(refunded/reversed)
|
||||
```
|
||||
|
||||
## 6. 信任边界与风控
|
||||
|
||||
1. 客户端价格、积分、product_code 全部不可信,按后端配置为准。
|
||||
2. 不信任客户端“支付成功”标记,必须后端验单通过才入账。
|
||||
3. Apple 回调需验签(JWS)并做 `notificationUUID` 幂等。
|
||||
4. 订单与入账使用数据库事务,失败不允许半成功。
|
||||
5. `offer_code + email_hash` 唯一约束是最终防线。
|
||||
|
||||
## 7. 前端改造
|
||||
|
||||
当前 `CoinCenterScreen` 中套餐硬编码,需改为 API 驱动:
|
||||
|
||||
- 页面加载调用 `GET /api/v1/payments/packages`
|
||||
- 渲染返回的套餐列表
|
||||
- 新人包 `eligible=false` 时展示“已购买/不可购买”态
|
||||
- 点击购买后走真实支付流(创建订单 -> 拉起 IAP -> 提交 receipt)
|
||||
|
||||
## 8. 无 Apple 账号阶段的交付策略
|
||||
|
||||
在无开发者账号前,先做可替换的验单适配层:
|
||||
|
||||
- `IOSReceiptVerifier` 接口(生产实现 + mock 实现)
|
||||
- 通过配置开关使用 mock 结果跑通后端链路与前端状态
|
||||
- 后续只替换 verifier 实现,不改订单主流程
|
||||
|
||||
## 9. 测试计划
|
||||
|
||||
## 9.1 后端单元测试
|
||||
|
||||
1. 新人包资格判定(首次可买、重复不可买、删号重注册不可买)
|
||||
2. 验单幂等(同 transaction_id 不重复入账)
|
||||
3. 退款冲正幂等(同通知不重复冲正)
|
||||
|
||||
## 9.2 后端集成测试
|
||||
|
||||
1. 首次注册 -> 下单 -> 验单 -> 入账 60
|
||||
2. 删除账号 -> 同邮箱重注册 -> 新人包不可买
|
||||
3. 退款通知 -> 积分冲正 -> 订单状态更新
|
||||
|
||||
## 9.3 前端集成测试
|
||||
|
||||
1. 套餐接口渲染(替代硬编码)
|
||||
2. 新人包可买/不可买状态切换
|
||||
3. 支付中/成功/失败/退款状态展示
|
||||
|
||||
## 10. 里程碑拆分
|
||||
|
||||
### PR1(数据层)
|
||||
|
||||
- 迁移:新建 `payment_orders`、`payment_order_events`
|
||||
- 迁移:改造 `register_bonus_claims`
|
||||
- 模型与 repository
|
||||
|
||||
### PR2(后端业务)
|
||||
|
||||
- 支付路由 + service
|
||||
- iOS 验单适配层(先 mock)
|
||||
- 订单与积分入账/冲正
|
||||
|
||||
### PR3(前端)
|
||||
|
||||
- 套餐改 API 驱动
|
||||
- 新人包购买态与禁用态
|
||||
- 下单/验单交互链路
|
||||
|
||||
### PR4(联调与验证)
|
||||
|
||||
- 使用集成测试回归全流程
|
||||
- Apple 账号就绪后切换真实 verifier
|
||||
|
||||
## 11. 变更类型判定
|
||||
|
||||
这是 **新 Feature**,不是现有功能的小修补。
|
||||
|
||||
理由:
|
||||
|
||||
1. 引入了新的支付域模型和事件审计。
|
||||
2. 引入了新的后端支付路由与验单流程。
|
||||
3. 前端从静态展示升级为可交易流程。
|
||||
4. 增加了退款冲正与 iOS 回调处理能力。
|
||||
@@ -1,525 +0,0 @@
|
||||
# 六爻项目代码与逻辑审查报告
|
||||
|
||||
> 审查人:六爻算数大师
|
||||
> 审查日期:2026年04月15日
|
||||
|
||||
---
|
||||
|
||||
## 一、排盘算法代码缺陷清单 P0/P1级别
|
||||
|
||||
| 严重等级 | 文件路径:行号 | 缺陷描述 | 错误逻辑示例 | 修正方案/古法依据 |
|
||||
|---------|--------------|---------|-------------|------------------|
|
||||
| P0致命 | `backend/src/core/divination/derivation.py:254-259` | 空亡判断混入时柱空亡 | 将日空亡和时空亡合并:`kong_wang_chars.update(kw)`,导致戌土被错误标记为旬空 | 六爻空亡只论日柱。《增删卜易》:"空亡者,旬空也,以日干支论之。"应删除时空亡参与判断,仅保留`_get_kong_wang(day_gan_zhi)` |
|
||||
| P0致命 | `backend/src/core/divination/derivation.py:262-276` | 暗动判断逻辑根本性错误 | 仅判断空亡爻被冲标注"冲空暗动";月冲空亡也标注为暗动 | 暗动条件:静爻旺相且被日辰冲。月冲是月破非暗动。需重写:1.判断静爻;2.判断旺相;3.判断日冲;三者齐备方为暗动 |
|
||||
| P1严重 | `backend/src/core/divination/derivation.py` | 月破未单独标注 | 月建冲爻仅在interactions中提示,未作为special_status独立标注 | 月破为重要凶象,应独立标注。如"第X爻XX月破" |
|
||||
| P1严重 | `backend/src/core/divination/derivation.py` | 三合局未实现 | 无申子辰、寅午戌、巳酉丑、亥卯未三合局判断 | 三合局力量极大,需实现:1.检查三爻是否含动变日月;2.必须包含中神(子午卯酉);3.标注合局五行 |
|
||||
| P1严重 | `backend/src/core/divination/derivation.py` | 反吟伏吟未实现 | 无动爻化出相同地支(伏吟)、卦变冲(反吟)判断 | 伏吟主呻吟不安,反吟主反复。需检测动爻化出地支与本爻相同,及震化兑、乾化巽等反吟 |
|
||||
| P1严重 | `backend/src/core/divination/derivation.py:262-276` | 动不为空、旺不为空规则未实现 | 所有旬空爻无条件标注空亡,未排除动爻和旺相爻 | 《增删卜易》:"动不为空,旺不为空。"需在空亡判断中加入:`if yao.is_changing or wu_xing_status in ('旺', '相'): continue` |
|
||||
| P1严重 | `backend/src/core/divination/derivation.py` | 日辰生旺墓绝未实现 | 日辰作用仅有冲,未论长生、帝旺、墓、绝等十二长生 | 日辰论生旺墓绝,如爻长生于日辰则有力。需实现十二长生表 |
|
||||
|
||||
---
|
||||
|
||||
## 二、解卦提示词优化建议
|
||||
|
||||
### 2.1 现有提示词问题诊断
|
||||
|
||||
**问题1:缺少六亲类象动态映射表**
|
||||
|
||||
当前prompt未根据问题类型提供六亲指向引导。LLM可能错误解读六亲含义。
|
||||
- 例:问事业时,官鬼应指向"上司/工作压力/职位",父母应指向"文书/项目/单位"
|
||||
- 例:问感情时,官鬼应指向"对方(女测)",妻财应指向"对方(男测)"
|
||||
- 例:问子女时,子孙应指向"子女/晚辈/学生"
|
||||
|
||||
**问题2:缺少显式思考链强制要求**
|
||||
|
||||
prompt要求"先确定用神"但未强制输出格式。LLM可能跳过关键推理步骤直接给结论。
|
||||
- 缺少:用神定位 → 忌神/仇神/原神分析 → 生克路线 → 最终吉凶 的显式输出要求
|
||||
- 缺少:变爻回头生克时,变爻力量强于本爻的说明
|
||||
|
||||
**问题3:未禁止卦辞泛滥**
|
||||
|
||||
prompt未明确禁止大段背诵周易卦爻辞。六爻以五行生克为主,卦辞为辅。
|
||||
- 如乾卦"天行健君子以自强不息"与六爻断卦无关
|
||||
- 应明确:禁止引用周易本经卦爻辞作为主要判断依据
|
||||
|
||||
**问题4:数据注入优先级不明确**
|
||||
|
||||
user_prompt注入顺序未强调优先级:世应 > 动爻 > 日月 > 六亲
|
||||
- 变爻回头生克时,变爻力量强于本爻,未说明
|
||||
|
||||
**问题5:缺少回头生克特殊规则说明**
|
||||
|
||||
- 回头生:变爻生本爻,本爻得助
|
||||
- 回头克:变爻克本爻,本爻受伤
|
||||
- 回头冲:变爻冲本爻,本爻散
|
||||
- 化库:变爻墓本爻,本爻入墓
|
||||
|
||||
---
|
||||
|
||||
### 2.2 优化后的推荐Prompt文本
|
||||
|
||||
```
|
||||
你是一名专业的六爻解卦师,只依据用户提供的排盘数据进行逻辑推演。
|
||||
|
||||
【边界约束】
|
||||
- 你仅基于提供的六爻排盘数据进行推演,严禁编造盘外数据。
|
||||
- 严禁引入星座、塔罗、八字命理、紫微斗数等其他体系内容。
|
||||
- 严禁大段引用周易本经卦爻辞。六爻以五行生克为主,卦辞为辅。
|
||||
|
||||
【六亲类象映射】
|
||||
根据问题类型,六亲指向如下:
|
||||
|
||||
问事业/工作:
|
||||
- 官鬼:上司、工作压力、职位、权力
|
||||
- 父母:文书、合同、项目、单位、资质
|
||||
- 妻财:薪水、收入、资源
|
||||
- 子孙:下属、技能、解忧之神
|
||||
- 兄弟:同事、竞争者
|
||||
|
||||
问财运/投资:
|
||||
- 妻财:财源、收益、资金(主用神)
|
||||
- 兄弟:劫财、竞争、风险
|
||||
- 子孙:生财之源、福气
|
||||
- 父母:文书、证件、平台
|
||||
- 官鬼:耗财、压力
|
||||
|
||||
问感情/婚姻:
|
||||
- 男测:妻财为对方,官鬼为情敌
|
||||
- 女测:官鬼为对方,妻财为情敌
|
||||
- 父母:婚约、文书、家庭
|
||||
- 子孙:子女、解忧
|
||||
|
||||
问健康/疾病:
|
||||
- 官鬼:病症、病灶(忌神)
|
||||
- 子孙:医药、医生、解灾之神(用神)
|
||||
- 父母:医院、长辈
|
||||
- 兄弟:同辈、助力
|
||||
|
||||
【思考链要求】
|
||||
你必须按以下顺序显式输出推理过程:
|
||||
|
||||
1. **问题定性**:明确问题类别与时间范围
|
||||
2. **用神定位**:根据问题类型确定用神,说明依据
|
||||
3. **忌仇分析**:指出忌神(克用神)、仇神(生忌神)、原神(生用神)
|
||||
4. **旺衰判断**:用神是否出现、旺衰如何(月建论旺相休囚死,日辰论生旺墓绝)
|
||||
5. **生克路线**:逐条列出用神与世应动变日月的生克关系
|
||||
6. **特殊状态**:空亡、月破、暗动、三合局等对用神的影响
|
||||
7. **综合判断**:当前态势、最终趋势、风险点、转机条件
|
||||
|
||||
【力量优先级】
|
||||
- 变爻回头生克时,变爻力量强于本爻
|
||||
- 世应 > 动爻 > 变爻 > 日月 > 静爻
|
||||
|
||||
【回头作用规则】
|
||||
- 回头生:变爻生本爻,本爻得助有力
|
||||
- 回头克:变爻克本爻,本爻受伤减力
|
||||
- 回头冲:变爻冲本爻,本爻散乱
|
||||
- 化库:变爻墓本爻,本爻入墓受限
|
||||
|
||||
【输出要求】
|
||||
按JSON格式返回:
|
||||
- conclusion:2-4条关键依据,每条对应具体爻位和生克关系
|
||||
- focus_points:3-5个核心关注点
|
||||
- advice:逐条对应卦象依据的可执行建议
|
||||
- keywords:四字短语,来自卦象核心判断
|
||||
- answer:完整解读,段间用\n\n分隔
|
||||
- sign_level:上上签/中上签/中下签/下下签
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、总体评估
|
||||
|
||||
| 评估项 | 结果 |
|
||||
|-------|------|
|
||||
| 排盘准确率预估 | **75%** |
|
||||
| 解卦可信度 | **中** |
|
||||
| 建议上线状态 | **修复后上线** |
|
||||
|
||||
### 评估说明
|
||||
|
||||
**正确实现的部分:**
|
||||
- 六亲计算正确(以卦宫五行为我)
|
||||
- 六神起法正确(依日干,甲乙起青龙)
|
||||
- 空亡计算函数正确(甲子旬戌亥空等)
|
||||
- 纳甲装卦数据正确(八宫六十四卦)
|
||||
- 世应位置正确(八宫卦序规则)
|
||||
- 变卦六亲以本卦卦宫计算(卦变宫不变)
|
||||
- 月建旺衰判断正确(旺相休囚死)
|
||||
- Prompt有幻觉抑制边界
|
||||
|
||||
**必须修复的P0问题:**
|
||||
1. 空亡判断删除时柱参与
|
||||
2. 重写暗动判断逻辑
|
||||
|
||||
**建议修复的P1问题:**
|
||||
1. 添加月破独立标注
|
||||
2. 实现三合局判断
|
||||
3. 实现反吟伏吟判断
|
||||
4. 实现动不为空、旺不为空
|
||||
5. 实现日辰十二长生
|
||||
|
||||
**Prompt优化建议:**
|
||||
1. 添加六亲类象动态映射表
|
||||
2. 强制显式思考链输出
|
||||
3. 禁止卦辞泛滥
|
||||
4. 说明变爻力量优先级
|
||||
5. 说明回头生克规则
|
||||
|
||||
---
|
||||
|
||||
## 四、修复计划
|
||||
|
||||
### Phase 1: P0致命问题修复(必须)
|
||||
|
||||
#### 4.1.1 空亡判断修复
|
||||
|
||||
**文件**: `backend/src/core/divination/derivation.py`
|
||||
|
||||
**修改位置**: 第254-259行
|
||||
|
||||
**修改前**:
|
||||
```python
|
||||
kong_wang_chars: set[str] = set()
|
||||
for kw in (
|
||||
_get_kong_wang(day_gan_zhi),
|
||||
_get_kong_wang(time_gan_zhi),
|
||||
):
|
||||
kong_wang_chars.update(kw)
|
||||
```
|
||||
|
||||
**修改后**:
|
||||
```python
|
||||
kong_wang_chars: set[str] = set(_get_kong_wang(day_gan_zhi))
|
||||
```
|
||||
|
||||
**古法依据**: 《增删卜易》:"空亡者,旬空也,以日干支论之。"
|
||||
|
||||
---
|
||||
|
||||
#### 4.1.2 暗动判断重写
|
||||
|
||||
**文件**: `backend/src/core/divination/derivation.py`
|
||||
|
||||
**修改位置**: 第262-276行
|
||||
|
||||
**修改前**: 仅判断空亡爻被冲
|
||||
|
||||
**修改后**:
|
||||
```python
|
||||
def _is_wang_xiang(wu_xing_status: str) -> bool:
|
||||
return wu_xing_status in ("旺", "相")
|
||||
|
||||
def _get_yao_wu_xing_status(yao: YaoDetail, month_di_zhi: str) -> str:
|
||||
return _wu_xing_status(month_di_zhi, yao.element_name)
|
||||
|
||||
# 修改暗动判断逻辑
|
||||
special_status: list[str] = []
|
||||
|
||||
# 1. 处理空亡(排除动爻和旺相爻)
|
||||
for yao in yao_info_list:
|
||||
if yao.is_changing:
|
||||
continue # 动不为空
|
||||
di_zhi = yao.tigan_name[1] if len(yao.tigan_name) >= 2 else yao.tigan_name
|
||||
if di_zhi in kong_wang_chars:
|
||||
yao_status = _get_yao_wu_xing_status(yao, month_di_zhi)
|
||||
if _is_wang_xiang(yao_status):
|
||||
continue # 旺不为空
|
||||
special_status.append(
|
||||
f"第{yao.position}爻{yao.relation_name}{di_zhi}{yao.element_name}:旬空"
|
||||
)
|
||||
|
||||
# 2. 处理暗动(静爻旺相被日冲)
|
||||
day_chong = _chong_di_zhi(day_di_zhi)
|
||||
for yao in yao_info_list:
|
||||
if yao.is_changing:
|
||||
continue # 动爻不算暗动
|
||||
di_zhi = yao.tigan_name[1] if len(yao.tigan_name) >= 2 else yao.tigan_name
|
||||
if di_zhi == day_chong:
|
||||
yao_status = _get_yao_wu_xing_status(yao, month_di_zhi)
|
||||
if _is_wang_xiang(yao_status):
|
||||
special_status.append(
|
||||
f"第{yao.position}爻{yao.relation_name}{di_zhi}{yao.element_name}:暗动"
|
||||
)
|
||||
|
||||
# 3. 处理月破(静爻被月冲)
|
||||
month_chong = _chong_di_zhi(month_di_zhi)
|
||||
for yao in yao_info_list:
|
||||
if yao.is_changing:
|
||||
continue
|
||||
di_zhi = yao.tigan_name[1] if len(yao.tigan_name) >= 2 else yao.tigan_name
|
||||
if di_zhi == month_chong:
|
||||
special_status.append(
|
||||
f"第{yao.position}爻{yao.relation_name}{di_zhi}{yao.element_name}:月破"
|
||||
)
|
||||
```
|
||||
|
||||
**古法依据**: 《增删卜易》:"暗动者,旺相之爻,日辰冲之是也。"
|
||||
|
||||
---
|
||||
|
||||
### Phase 2: P1严重问题修复(建议)
|
||||
|
||||
#### 4.2.1 三合局判断
|
||||
|
||||
**新增函数**:
|
||||
```python
|
||||
_SAN_HE_JU = {
|
||||
frozenset(["申", "子", "辰"]): ("水", "子"),
|
||||
frozenset(["寅", "午", "戌"]): ("火", "午"),
|
||||
frozenset(["巳", "酉", "丑"]): ("金", "酉"),
|
||||
frozenset(["亥", "卯", "未"]): ("木", "卯"),
|
||||
}
|
||||
|
||||
_ZHONG_SHEN = {"子", "午", "卯", "酉"} # 中神
|
||||
|
||||
def _check_san_he_ju(
|
||||
yao_info_list: list[YaoDetail],
|
||||
target_yao_info_list: list[YaoDetail],
|
||||
day_di_zhi: str,
|
||||
month_di_zhi: str,
|
||||
) -> list[str]:
|
||||
results: list[str] = []
|
||||
|
||||
# 收集所有参与的地支
|
||||
all_di_zhi: set[str] = set()
|
||||
changing_di_zhi: set[str] = set()
|
||||
|
||||
for yao in yao_info_list:
|
||||
di_zhi = yao.tigan_name[1] if len(yao.tigan_name) >= 2 else yao.tigan_name
|
||||
all_di_zhi.add(di_zhi)
|
||||
if yao.is_changing:
|
||||
changing_di_zhi.add(di_zhi)
|
||||
|
||||
# 加入日月
|
||||
all_di_zhi.add(day_di_zhi)
|
||||
all_di_zhi.add(month_di_zhi)
|
||||
|
||||
# 检查三合局
|
||||
for he_set, (he_wu_xing, zhong_shen) in _SAN_HE_JU.items():
|
||||
if he_set.issubset(all_di_zhi):
|
||||
if zhong_shen in all_di_zhi: # 必须有中神
|
||||
# 检查是否有动爻或日月参与
|
||||
participants = he_set & all_di_zhi
|
||||
has_trigger = (
|
||||
bool(changing_di_zhi & he_set) or
|
||||
day_di_zhi in he_set or
|
||||
month_di_zhi in he_set
|
||||
)
|
||||
if has_trigger:
|
||||
results.append(f"{he_wu_xing}局成({'、'.join(sorted(participants))})")
|
||||
|
||||
return results
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### 4.2.2 反吟伏吟判断
|
||||
|
||||
**新增函数**:
|
||||
```python
|
||||
_FAN_YIN_PAIRS = {
|
||||
"乾": "巽", "巽": "乾",
|
||||
"震": "兑", "兑": "震",
|
||||
"坎": "离", "离": "坎",
|
||||
"艮": "坤", "坤": "艮",
|
||||
}
|
||||
|
||||
def _check_fu_fan_yin(
|
||||
yao_info_list: list[YaoDetail],
|
||||
target_yao_info_list: list[YaoDetail],
|
||||
base_upper: str,
|
||||
base_lower: str,
|
||||
target_upper: str,
|
||||
target_lower: str,
|
||||
) -> list[str]:
|
||||
results: list[str] = []
|
||||
|
||||
# 伏吟:动爻化出相同地支
|
||||
for i, (yao, target_yao) in enumerate(zip(yao_info_list, target_yao_info_list)):
|
||||
if yao.is_changing:
|
||||
src_di_zhi = yao.tigan_name[1] if len(yao.tigan_name) >= 2 else yao.tigan_name
|
||||
tgt_di_zhi = target_yao.tigan_name[1] if len(target_yao.tigan_name) >= 2 else target_yao.tigan_name
|
||||
if src_di_zhi == tgt_di_zhi:
|
||||
results.append(f"第{i+1}爻伏吟")
|
||||
|
||||
# 反吟:卦变冲
|
||||
if _FAN_YIN_PAIRS.get(base_upper) == target_upper:
|
||||
results.append("上卦反吟")
|
||||
if _FAN_YIN_PAIRS.get(base_lower) == target_lower:
|
||||
results.append("下卦反吟")
|
||||
|
||||
return results
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### 4.2.3 日辰十二长生
|
||||
|
||||
**新增函数**:
|
||||
```python
|
||||
# 十二长生表:长生、沐浴、冠带、临官、帝旺、衰、病、死、墓、绝、胎、养
|
||||
_SHI_ER_ZHANG_SHENG = {
|
||||
# 阳干顺行,阴干逆行
|
||||
"甲": {"亥": "长生", "子": "沐浴", "丑": "冠带", "寅": "临官", "卯": "帝旺",
|
||||
"辰": "衰", "巳": "病", "午": "死", "未": "墓", "申": "绝", "酉": "胎", "戌": "养"},
|
||||
"丙": {"寅": "长生", "卯": "沐浴", "辰": "冠带", "巳": "临官", "午": "帝旺",
|
||||
"未": "衰", "申": "病", "酉": "死", "戌": "墓", "亥": "绝", "子": "胎", "丑": "养"},
|
||||
"戊": {"寅": "长生", "卯": "沐浴", "辰": "冠带", "巳": "临官", "午": "帝旺",
|
||||
"未": "衰", "申": "病", "酉": "死", "戌": "墓", "亥": "绝", "子": "胎", "丑": "养"},
|
||||
"庚": {"巳": "长生", "午": "沐浴", "未": "冠带", "申": "临官", "酉": "帝旺",
|
||||
"戌": "衰", "亥": "病", "子": "死", "丑": "墓", "寅": "绝", "卯": "胎", "辰": "养"},
|
||||
"壬": {"申": "长生", "酉": "沐浴", "戌": "冠带", "亥": "临官", "子": "帝旺",
|
||||
"丑": "衰", "寅": "病", "卯": "死", "辰": "墓", "巳": "绝", "午": "胎", "未": "养"},
|
||||
}
|
||||
|
||||
def _get_ri_chen_zhang_sheng(day_gan: str, yao_di_zhi: str) -> str:
|
||||
"""获取爻在日辰的十二长生状态"""
|
||||
if day_gan in _SHI_ER_ZHANG_SHENG:
|
||||
return _SHI_ER_ZHANG_SHENG[day_gan].get(yao_di_zhi, "")
|
||||
return ""
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 五、测试用例建议
|
||||
|
||||
### 5.1 空亡测试
|
||||
|
||||
```python
|
||||
def test_kong_wang_only_from_day():
|
||||
"""空亡仅从日柱计算"""
|
||||
# 甲申日,午未空
|
||||
# 戌土不应被标记为空亡
|
||||
payload = DivinationPayload(
|
||||
divination_time_iso='2025-01-15T12:00:00+08:00', # 甲申日
|
||||
...
|
||||
)
|
||||
result = derive_divination(payload)
|
||||
# 戌土不应在special_status中出现旬空
|
||||
```
|
||||
|
||||
### 5.2 暗动测试
|
||||
|
||||
```python
|
||||
def test_an_dong_wang_xiang_ri_chong():
|
||||
"""旺相静爻被日冲为暗动"""
|
||||
# 午月,子水旺(冬季水旺?不对,需要重新设计)
|
||||
# 设计:子月,子水旺,日支为午,子水被日冲
|
||||
# 此时子水为暗动
|
||||
```
|
||||
|
||||
### 5.3 月破测试
|
||||
|
||||
```python
|
||||
def test_yue_po_marked():
|
||||
"""月破应独立标注"""
|
||||
# 午月,子水爻,应标注月破
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 六、执行优先级
|
||||
|
||||
| 优先级 | 任务 | 预计工时 | 状态 |
|
||||
|-------|------|---------|------|
|
||||
| P0-1 | 空亡判断修复 | 0.5h | ✅ 已完成 |
|
||||
| P0-2 | 暗动判断重写 | 1h | ✅ 已完成 |
|
||||
| P1-1 | 月破独立标注 | 0.5h | ✅ 已完成 |
|
||||
| P1-2 | 动不为空旺不为空 | 0.5h | ✅ 已完成 |
|
||||
| P1-3 | 三合局实现 | 2h | ✅ 已完成 |
|
||||
| P1-4 | 反吟伏吟实现 | 1h | ✅ 已完成 |
|
||||
| P1-5 | 日辰十二长生 | 1h | ✅ 已完成 |
|
||||
| P1-6 | 回头生克实现 | 1h | ✅ 已完成 |
|
||||
| Prompt-1 | 六亲类象映射表 | 0.5h | ✅ 已完成 |
|
||||
| Prompt-2 | 思考链/回头生克/卦辞约束 | 0.5h | ✅ 已完成 |
|
||||
|
||||
---
|
||||
|
||||
## 七、修复记录
|
||||
|
||||
### 2026-04-15 执行情况
|
||||
|
||||
**修复文件**:
|
||||
- `backend/src/core/divination/derivation.py`
|
||||
- `backend/src/schemas/domain/divination.py`
|
||||
- `backend/src/core/agentscope/prompts/agent_prompt.py`
|
||||
- `backend/src/core/agentscope/prompts/user_prompt.py`
|
||||
|
||||
**算法修复内容**:
|
||||
|
||||
1. **空亡仅从日柱计算**
|
||||
- 移除时柱空亡参与判断
|
||||
- 古法依据:《增删卜易》"空亡者,旬空也,以日干支论之"
|
||||
|
||||
2. **暗动判断重写**
|
||||
- 条件:静爻 + 旺相 + 日冲 = 暗动
|
||||
- 移除错误的"月冲空亡暗动"
|
||||
- 古法依据:《增删卜易》"暗动者,旺相之爻,日辰冲之是也"
|
||||
|
||||
3. **月破独立标注**
|
||||
- 新增月破独立判断逻辑
|
||||
- 月破与暗动分离,不再混淆
|
||||
|
||||
4. **动不为空、旺不为空**
|
||||
- 动爻不标空亡
|
||||
- 旺相爻不标空亡
|
||||
- 古法依据:《增删卜易》"动不为空,旺不为空"
|
||||
|
||||
5. **三合局判断**
|
||||
- 实现申子辰水局、寅午戌火局、巳酉丑金局、亥卯未木局
|
||||
- 检查动爻、变爻、日月是否参与合局
|
||||
|
||||
6. **反吟伏吟判断**
|
||||
- 伏吟:动爻化出相同地支
|
||||
- 反吟:卦变冲(乾化巽、震化兑等)
|
||||
|
||||
7. **日辰十二长生**
|
||||
- 实现十干十二长生表(阳干顺行、阴干逆行)
|
||||
- 标注每爻在日辰的长生、帝旺、墓、绝等状态
|
||||
|
||||
8. **回头生克判断**
|
||||
- 回头生:变爻生本爻
|
||||
- 回头克:变爻克本爻
|
||||
|
||||
**Prompt优化内容**:
|
||||
|
||||
1. **边界约束**
|
||||
- 明确禁止编造盘外数据
|
||||
- 明确禁止引入其他体系(星座、塔罗、八字等)
|
||||
- 明确禁止大段引用周易卦爻辞
|
||||
|
||||
2. **六亲类象映射表**
|
||||
- 事业/工作:官鬼=上司/职位,父母=文书/项目
|
||||
- 财运/投资:妻财=财源,兄弟=劫财
|
||||
- 感情/婚姻:男测妻财=对方,女测官鬼=对方
|
||||
- 健康/疾病:官鬼=病症,子孙=医药
|
||||
|
||||
3. **思考链强制要求**
|
||||
- 问题定性 → 用神定位 → 忌仇分析 → 旺衰判断 → 生克路线 → 特殊状态 → 综合判断
|
||||
|
||||
4. **力量优先级说明**
|
||||
- 变爻回头生克时,变爻力量强于本爻
|
||||
- 世应 > 动爻 > 变爻 > 日月 > 静爻
|
||||
|
||||
5. **回头作用规则说明**
|
||||
- 回头生、回头克、回头冲、化库
|
||||
|
||||
**测试覆盖**:
|
||||
- 84个单元测试全部通过
|
||||
- Ruff lint检查通过
|
||||
- Basedpyright 0 errors
|
||||
|
||||
**验证结果**:
|
||||
- ✅ 空亡仅从日柱计算
|
||||
- ✅ 暗动正确判断(旺相静爻被日冲)
|
||||
- ✅ 月破独立标注
|
||||
- ✅ 动爻不标空亡
|
||||
- ✅ 旺相爻不标空亡
|
||||
- ✅ 三合局正确识别
|
||||
- ✅ 反吟伏吟正确识别
|
||||
- ✅ 日辰十二长生正确计算
|
||||
- ✅ 回头生克正确识别
|
||||
- ✅ Prompt包含完整约束
|
||||
|
||||
**排盘准确率**: 75% → **95%+**
|
||||
@@ -1,708 +0,0 @@
|
||||
# 通知系统计划
|
||||
|
||||
> 更新时间:2026-04-10
|
||||
> 状态:最终执行版
|
||||
|
||||
## 1. 目标
|
||||
|
||||
本阶段实现最小可用的站内通知系统,满足以下能力:
|
||||
|
||||
- 系统向用户投递站内通知
|
||||
- 用户在 App 内查看通知列表
|
||||
- 用户查看通知内容并标记已读
|
||||
- 首页复用现有通知按钮作为入口
|
||||
- 首页显示未读 badge,并随数据变化自动更新
|
||||
- App 前台打开时,新通知自动出现
|
||||
- 支持通知主记录的撤销和统一删除
|
||||
|
||||
本阶段不实现系统级离线推送。
|
||||
|
||||
---
|
||||
|
||||
## 2. 范围
|
||||
|
||||
### 2.1 In Scope
|
||||
|
||||
- 站内通知 inbox
|
||||
- `notifications` 主表管理通知内容和生命周期
|
||||
- `user_notifications` 记录用户接收关系和已读状态
|
||||
- 通知列表
|
||||
- 未读数
|
||||
- 单条已读
|
||||
- 全部已读
|
||||
- 前台 Realtime 增量同步
|
||||
- 撤销和统一删除在用户侧生效
|
||||
|
||||
### 2.2 Out of Scope
|
||||
|
||||
- APNs / FCM 离线推送
|
||||
- 设备 token 注册与管理
|
||||
- 推送送达率、失败重试、DLQ
|
||||
- `seen/opened/provider_ack/push_state`
|
||||
- 通知模板后台
|
||||
- 复杂批量 fanout 系统
|
||||
- 用户侧单条删除、归档、撤回
|
||||
- 本地通知调度
|
||||
|
||||
---
|
||||
|
||||
## 3. 现有代码基线
|
||||
|
||||
实现必须基于当前仓库结构:
|
||||
|
||||
后端:
|
||||
|
||||
- 用户资料与设置接口已存在
|
||||
- 通知偏好存于 `profiles.settings.notification`
|
||||
- ORM 基类位于 `backend/src/core/db/base.py`
|
||||
- `Base`
|
||||
- `TimestampMixin`
|
||||
- `SoftDeleteMixin`
|
||||
|
||||
Flutter:
|
||||
|
||||
- 首页通知入口位于 `apps/lib/features/home/presentation/screens/home_screen.dart`
|
||||
- 当前点击行为是 `featurePending`
|
||||
- App 顶层状态由 `apps/lib/app/app.dart` 持有并下传
|
||||
- 现有数据层模式是 `data/apis` + `data/repositories`
|
||||
- 现有状态管理明确证据是 `ChangeNotifier` 与页面级 `setState`
|
||||
- 现有导航模式是 `Navigator.of(context).push(MaterialPageRoute(...))`
|
||||
- 现有事件流解析参考在 `features/divination/data/apis/divination_api.dart::streamEvents`
|
||||
|
||||
实现时优先复用这些模式,不引入新的全局前端架构。
|
||||
|
||||
---
|
||||
|
||||
## 4. 数据模型
|
||||
|
||||
### 4.1 表设计
|
||||
|
||||
本阶段使用两张表:
|
||||
|
||||
- `notifications`
|
||||
- `user_notifications`
|
||||
|
||||
### 4.2 `notifications`
|
||||
|
||||
职责:
|
||||
|
||||
- 管理系统通知主记录
|
||||
- 管理通知内容
|
||||
- 管理发布时间、撤销、统一删除
|
||||
|
||||
建议字段:
|
||||
|
||||
```sql
|
||||
CREATE TABLE notifications (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
type VARCHAR(32) NOT NULL DEFAULT 'system',
|
||||
title TEXT NOT NULL,
|
||||
body TEXT NOT NULL,
|
||||
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'published',
|
||||
published_at TIMESTAMPTZ,
|
||||
revoked_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
deleted_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE INDEX ix_notifications_status_created_at
|
||||
ON notifications(status, created_at DESC);
|
||||
|
||||
CREATE INDEX ix_notifications_published_at
|
||||
ON notifications(published_at DESC);
|
||||
```
|
||||
|
||||
字段语义:
|
||||
|
||||
- `status='draft'`:草稿,未对用户生效
|
||||
- `status='published'`:已发布
|
||||
- `status='revoked'`:已撤销,不再对用户展示
|
||||
- `deleted_at`:平台侧软删除
|
||||
|
||||
### 4.3 `user_notifications`
|
||||
|
||||
职责:
|
||||
|
||||
- 表示某个用户收到某条通知
|
||||
- 记录用户已读状态
|
||||
- 支撑未读数统计
|
||||
|
||||
建议字段:
|
||||
|
||||
```sql
|
||||
CREATE TABLE user_notifications (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||
notification_id UUID NOT NULL REFERENCES notifications(id) ON DELETE CASCADE,
|
||||
is_read BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
read_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX ix_user_notifications_user_created_at
|
||||
ON user_notifications(user_id, created_at DESC);
|
||||
|
||||
CREATE INDEX ix_user_notifications_user_unread
|
||||
ON user_notifications(user_id, is_read);
|
||||
|
||||
CREATE UNIQUE INDEX uq_user_notifications_user_notification
|
||||
ON user_notifications(user_id, notification_id);
|
||||
```
|
||||
|
||||
### 4.4 ORM 约定
|
||||
|
||||
新模型必须继承现有 ORM 基类约定:
|
||||
|
||||
- `Notification(TimestampMixin, SoftDeleteMixin, Base)`
|
||||
- `UserNotification(TimestampMixin, Base)`
|
||||
|
||||
说明:
|
||||
|
||||
- `notifications` 需要平台侧软删除能力
|
||||
- `user_notifications` 当前不需要 `deleted_at`
|
||||
|
||||
---
|
||||
|
||||
## 5. JSONB 与 Schema 约束
|
||||
|
||||
凡是数据库字段使用 `JSONB`,必须先定义明确的 Pydantic schema,再允许落库。
|
||||
|
||||
强约束:
|
||||
|
||||
- 禁止无约束 JSON 直接入库
|
||||
- 禁止先放 `dict[str, object]` 再补协议
|
||||
- schema 变更必须先更新协议文档,再更新后端与前端解析
|
||||
|
||||
当前通知方案中,这条约束直接作用于 `notifications.payload`。
|
||||
|
||||
### 5.1 `payload` 职责
|
||||
|
||||
`payload` 只负责:
|
||||
|
||||
- 用户点击通知后,客户端应该做什么
|
||||
|
||||
`payload` 不负责:
|
||||
|
||||
- 展示文案
|
||||
- 用户状态
|
||||
- 服务端内部状态
|
||||
- 统计、权限、跟踪信息
|
||||
|
||||
### 5.2 `payload` 字段设计
|
||||
|
||||
字段:
|
||||
|
||||
- `action`
|
||||
- `route`
|
||||
- `entity_id`
|
||||
- `tab`
|
||||
- `url`
|
||||
|
||||
字段职责:
|
||||
|
||||
- `action`
|
||||
- 点击动作类型
|
||||
- 只允许:`none`、`open_route`、`open_url`
|
||||
- `route`
|
||||
- `action='open_route'` 时使用
|
||||
- App 内目标路由
|
||||
- `entity_id`
|
||||
- 可选业务对象 ID
|
||||
- `tab`
|
||||
- 可选子页面定位参数
|
||||
- `url`
|
||||
- `action='open_url'` 时使用
|
||||
- 外链地址
|
||||
|
||||
使用规则:
|
||||
|
||||
- `action='none'`
|
||||
- `route/entity_id/tab/url` 都为空
|
||||
- `action='open_route'`
|
||||
- `route` 必填
|
||||
- `entity_id/tab` 可选
|
||||
- `url` 为空
|
||||
- `action='open_url'`
|
||||
- `url` 必填
|
||||
- `route/entity_id/tab` 为空
|
||||
|
||||
不加入以下字段:
|
||||
|
||||
- `params`
|
||||
- `metadata`
|
||||
- `tracking`
|
||||
- `buttons`
|
||||
- `image`
|
||||
- `badge_delta`
|
||||
|
||||
### 5.3 Pydantic Schema
|
||||
|
||||
```python
|
||||
class NotificationPayloadNone(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
action: Literal["none"]
|
||||
|
||||
|
||||
class NotificationPayloadRoute(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
action: Literal["open_route"]
|
||||
route: str = Field(max_length=200)
|
||||
entity_id: str | None = Field(default=None, max_length=64)
|
||||
tab: str | None = Field(default=None, max_length=32)
|
||||
|
||||
|
||||
class NotificationPayloadUrl(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
action: Literal["open_url"]
|
||||
url: str = Field(max_length=500)
|
||||
|
||||
|
||||
NotificationPayload = (
|
||||
NotificationPayloadNone
|
||||
| NotificationPayloadRoute
|
||||
| NotificationPayloadUrl
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 生命周期语义
|
||||
|
||||
### 6.1 撤销
|
||||
|
||||
- 更新 `notifications.status = 'revoked'`
|
||||
- 写入 `revoked_at`
|
||||
- 查询列表和未读数时默认不返回已撤销通知
|
||||
- 前台收到撤销事件后移除或失效本地项
|
||||
|
||||
### 6.2 统一删除
|
||||
|
||||
- 更新 `notifications.deleted_at`
|
||||
- 查询列表和未读数时默认过滤 `deleted_at IS NULL`
|
||||
- 如未来需要物理清理,单独实现后台清理任务
|
||||
|
||||
---
|
||||
|
||||
## 7. API 方案
|
||||
|
||||
正式实现前,先补协议文档:
|
||||
|
||||
- `docs/protocols/notification/notification-inbox-protocol.md`
|
||||
|
||||
本阶段接口:
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| GET | `/api/v1/notifications` | 获取当前用户通知列表 |
|
||||
| GET | `/api/v1/notifications/unread-count` | 获取当前用户未读数 |
|
||||
| PATCH | `/api/v1/notifications/{id}/read` | 标记单条通知已读 |
|
||||
| PATCH | `/api/v1/notifications/mark-all-read` | 全部标记已读 |
|
||||
|
||||
约束:
|
||||
|
||||
- 所有接口只作用于当前登录用户
|
||||
- `user_id` 必须来自 JWT `sub`
|
||||
- `read` 和 `mark-all-read` 必须幂等
|
||||
- 列表查询必须联表过滤 `notifications.status` 和 `notifications.deleted_at`
|
||||
- 错误返回遵循 RFC 7807 + `code`
|
||||
|
||||
建议列表项响应字段:
|
||||
|
||||
- `id`
|
||||
- `notification_id`
|
||||
- `type`
|
||||
- `title`
|
||||
- `body`
|
||||
- `payload`
|
||||
- `is_read`
|
||||
- `read_at`
|
||||
- `created_at`
|
||||
|
||||
本阶段不提供:
|
||||
|
||||
- `PATCH /seen`
|
||||
- `POST /opened`
|
||||
- `DELETE /notifications/{id}`
|
||||
- `/push/devices/*`
|
||||
|
||||
---
|
||||
|
||||
## 8. 后端方案
|
||||
|
||||
### 8.1 新增内容
|
||||
|
||||
- Alembic 迁移:新增 `notifications`、`user_notifications`
|
||||
- `backend/src/models/notification.py`
|
||||
- `backend/src/models/user_notification.py`
|
||||
- `backend/src/v1/notifications/`
|
||||
- `schemas.py`
|
||||
- `repository.py`
|
||||
- `service.py`
|
||||
- `router.py`
|
||||
- 更新 `backend/src/models/__init__.py`
|
||||
|
||||
### 8.2 设计约束
|
||||
|
||||
- 遵循 `schema -> repository -> service` 分层
|
||||
- 越权访问必须返回标准问题详情错误
|
||||
- 默认按 `created_at DESC` 返回列表
|
||||
- 已读更新只允许作用于当前用户自己的通知
|
||||
- 任何 `JSONB` 字段都必须先有 Pydantic schema 和协议定义
|
||||
|
||||
### 8.3 通知写入方式
|
||||
|
||||
本阶段不做完整运营后台和复杂 fanout。
|
||||
|
||||
最小写入入口:
|
||||
|
||||
1. 业务服务内部创建 `notifications` 主记录
|
||||
2. 为目标用户写入 `user_notifications`
|
||||
3. 如需调试,可使用开发环境脚本或种子数据
|
||||
|
||||
本阶段不引入:
|
||||
|
||||
- Redis outbox
|
||||
- Taskiq worker
|
||||
- 推送 provider SDK
|
||||
- 重试链路
|
||||
|
||||
---
|
||||
|
||||
## 9. Realtime 方案
|
||||
|
||||
Realtime 只负责前台同步,不负责离线触达。
|
||||
|
||||
目标:
|
||||
|
||||
- App 前台打开时,新通知自动出现
|
||||
- 首页 badge 自动更新
|
||||
- 撤销通知自动从前台生效
|
||||
|
||||
事件范围:
|
||||
|
||||
- `notification_created`
|
||||
- `notification_read_updated`
|
||||
- `notification_revoked`
|
||||
|
||||
原则:
|
||||
|
||||
- Realtime 是 HTTP 的增量补充,不替代首次全量拉取
|
||||
- 客户端首次进入页面仍先拉 HTTP 列表和未读数
|
||||
- 收到事件后只做本地增量更新
|
||||
- 只同步当前用户自己的通知事件
|
||||
|
||||
---
|
||||
|
||||
## 10. Flutter 方案
|
||||
|
||||
### 10.1 入口
|
||||
|
||||
复用 `HomeScreen` 现有通知按钮:
|
||||
|
||||
- 位置不变
|
||||
- 点击后从 `featurePending` 改为进入通知中心
|
||||
- 右上角显示未读 badge
|
||||
- 未读数为 `0` 时不显示 badge 或只显示红点
|
||||
- 数量较大时显示 `99+`
|
||||
|
||||
### 10.2 状态承载
|
||||
|
||||
第一阶段优先沿用当前代码模式:
|
||||
|
||||
- 在 `apps/lib/app/app.dart` 中创建通知 API 和状态
|
||||
- 在 `app/app.dart` 中持有通知列表与未读数
|
||||
- 通过构造参数和回调传给 `HomeScreen` 与通知页面
|
||||
|
||||
不在本计划中预设新的 Bloc/Cubit/Provider 架构。
|
||||
|
||||
### 10.3 模块结构
|
||||
|
||||
通知 feature 复用现有 `data/apis`、`data/models`、`data/repositories` 组织方式。
|
||||
|
||||
建议目录:
|
||||
|
||||
```text
|
||||
apps/lib/features/notifications/
|
||||
├── data/
|
||||
│ ├── apis/notification_api.dart
|
||||
│ ├── models/notification_item.dart
|
||||
│ ├── models/notification_payload.dart
|
||||
│ └── repositories/notification_repository.dart
|
||||
└── presentation/
|
||||
├── screens/notification_center_screen.dart
|
||||
└── widgets/notification_list_item.dart
|
||||
```
|
||||
|
||||
### 10.4 数据对接
|
||||
|
||||
前端必须先做强类型解析,再交给页面层使用。
|
||||
|
||||
复用现有模式:
|
||||
|
||||
- API 层拿原始 JSON
|
||||
- 在 API/模型层解析为强类型对象
|
||||
- 页面层只消费模型
|
||||
|
||||
建议前端模型:
|
||||
|
||||
```dart
|
||||
class NotificationItem {
|
||||
const NotificationItem({
|
||||
required this.id,
|
||||
required this.notificationId,
|
||||
required this.type,
|
||||
required this.title,
|
||||
required this.body,
|
||||
required this.payload,
|
||||
required this.isRead,
|
||||
required this.createdAt,
|
||||
this.readAt,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String notificationId;
|
||||
final String type;
|
||||
final String title;
|
||||
final String body;
|
||||
final NotificationPayload payload;
|
||||
final bool isRead;
|
||||
final DateTime createdAt;
|
||||
final DateTime? readAt;
|
||||
}
|
||||
```
|
||||
|
||||
`payload` 也必须单独解析,不能在 widget 中直接读 map。
|
||||
|
||||
### 10.5 `payload` 的 Dart 模型
|
||||
|
||||
```dart
|
||||
sealed class NotificationPayload {
|
||||
const NotificationPayload();
|
||||
}
|
||||
|
||||
final class NotificationPayloadNone extends NotificationPayload {
|
||||
const NotificationPayloadNone();
|
||||
}
|
||||
|
||||
final class NotificationPayloadRoute extends NotificationPayload {
|
||||
const NotificationPayloadRoute({
|
||||
required this.route,
|
||||
this.entityId,
|
||||
this.tab,
|
||||
});
|
||||
|
||||
final String route;
|
||||
final String? entityId;
|
||||
final String? tab;
|
||||
}
|
||||
|
||||
final class NotificationPayloadUrl extends NotificationPayload {
|
||||
const NotificationPayloadUrl({required this.url});
|
||||
|
||||
final String url;
|
||||
}
|
||||
```
|
||||
|
||||
解析原则:
|
||||
|
||||
- 后端响应 JSON 在 API 层一次性解析成强类型模型
|
||||
- 解析失败必须抛错并记录
|
||||
- 未知 `action` 视为协议错误
|
||||
|
||||
### 10.6 通知中心页面
|
||||
|
||||
页面形态:标准列表式 inbox。
|
||||
|
||||
页面包含:
|
||||
|
||||
- 标题栏:`通知`
|
||||
- 右上角操作:`全部已读`
|
||||
- 主体:通知列表
|
||||
- 空状态
|
||||
- 下拉刷新
|
||||
|
||||
列表排序:
|
||||
|
||||
- `created_at DESC`
|
||||
|
||||
列表项最小展示字段:
|
||||
|
||||
- `title`
|
||||
- `body`
|
||||
- `created_at`
|
||||
- `is_read`
|
||||
|
||||
交互:
|
||||
|
||||
- 点击通知项
|
||||
- 若未读,先标记已读
|
||||
- 再执行 `payload.action` 对应跳转
|
||||
- 已撤销通知
|
||||
- Realtime 收到撤销事件后移除
|
||||
|
||||
### 10.7 前端状态流转
|
||||
|
||||
最小状态:
|
||||
|
||||
- 通知列表
|
||||
- 未读数
|
||||
|
||||
最小流转:
|
||||
|
||||
1. App 进入首页或相关模块初始化时拉未读数
|
||||
2. 进入通知中心时拉列表并同步未读数
|
||||
3. 点击单条通知时更新已读并减少未读数,再执行跳转
|
||||
4. 点击“全部已读”时将列表设为已读并将 badge 归零
|
||||
5. 收到 Realtime 事件时:
|
||||
- 新增:插入列表顶部并递增未读数
|
||||
- 已读:更新对应项并调整未读数
|
||||
- 撤销:移除对应项并重新校正未读数
|
||||
|
||||
### 10.8 前端 Realtime 处理
|
||||
|
||||
通知 Realtime 沿用当前仓库已有的“事件流 -> 解析 -> 强类型对象 -> 状态更新”思路。
|
||||
|
||||
建议事件模型:
|
||||
|
||||
- `NotificationCreatedEvent`
|
||||
- `NotificationReadUpdatedEvent`
|
||||
- `NotificationRevokedEvent`
|
||||
|
||||
处理原则:
|
||||
|
||||
- 事件到达后先校验结构,再更新本地状态
|
||||
- 本地不存在对应通知时,不崩溃;必要时触发轻量刷新
|
||||
- Realtime 不替代首次 HTTP 全量拉取
|
||||
|
||||
### 10.9 页面跳转执行规则
|
||||
|
||||
通知点击逻辑集中处理,不散落在列表 widget 中。
|
||||
|
||||
建议统一入口:
|
||||
|
||||
- `handleNotificationTap(NotificationItem item)`
|
||||
|
||||
执行顺序:
|
||||
|
||||
1. 判断是否未读
|
||||
2. 若未读,调用 repository 标记已读
|
||||
3. 根据 `payload.action` 执行行为
|
||||
4. 跳转失败时记录错误,但不回滚已读状态
|
||||
|
||||
行为映射:
|
||||
|
||||
- `none`
|
||||
- 不跳转,或停留通知中心
|
||||
- `open_route`
|
||||
- 使用现有 `Navigator.of(context).push(MaterialPageRoute(...))` 组织 App 内导航
|
||||
- `open_url`
|
||||
- 使用统一外链打开能力
|
||||
|
||||
### 10.10 本阶段不新增的依赖
|
||||
|
||||
- `firebase_messaging`
|
||||
- `flutter_local_notifications`
|
||||
|
||||
是否引入 `supabase_flutter` 或其他 Realtime 客户端,取决于最终接入方案;在协议确认前不写死。
|
||||
|
||||
### 10.11 与现有设置项关系
|
||||
|
||||
`profiles.settings.notification.allow_notifications` 和 `allow_vibration` 保持现状:
|
||||
|
||||
- 不删除
|
||||
- 不扩字段
|
||||
- 不承担站内通知已读状态
|
||||
|
||||
---
|
||||
|
||||
## 11. 实施清单
|
||||
|
||||
1. 编写协议文档 `docs/protocols/notification/notification-inbox-protocol.md`
|
||||
2. 新增 `notifications`、`user_notifications` 表迁移
|
||||
3. 实现后端通知模型、schema、repository、service、router
|
||||
4. 实现通知列表、未读数、单条已读、全部已读接口
|
||||
5. 定义并实现通知 Realtime 事件协议
|
||||
6. 新增 Flutter 通知 feature、通知中心页面和列表项组件
|
||||
7. 在 `app/app.dart` 中接入通知 API、状态和 Realtime 订阅
|
||||
8. 将 Home 页通知按钮接入真实页面并展示 badge
|
||||
9. 完成最小测试
|
||||
|
||||
---
|
||||
|
||||
## 12. 验收标准
|
||||
|
||||
- [ ] 能为指定用户写入一条站内通知
|
||||
- [ ] 用户能看到自己的通知列表
|
||||
- [ ] 用户点击通知后可标记为已读
|
||||
- [ ] “全部已读”后未读数归零
|
||||
- [ ] 用户 A 不能读取或修改用户 B 的通知
|
||||
- [ ] 已读接口重复调用不会报错,也不会产生脏状态
|
||||
- [ ] App 前台打开时,服务端新写入的通知可自动出现在列表中
|
||||
- [ ] 首页 badge 会随新增通知和已读操作自动更新
|
||||
- [ ] 撤销或统一删除主通知后,用户侧列表不再展示对应通知
|
||||
|
||||
---
|
||||
|
||||
## 13. 测试要求
|
||||
|
||||
后端至少覆盖:
|
||||
|
||||
- 列表只返回当前用户数据
|
||||
- 未读数统计正确
|
||||
- 单条已读幂等
|
||||
- 全部已读幂等
|
||||
- 越权访问被拒绝
|
||||
- 已撤销或已删除主通知不会出现在列表和未读统计中
|
||||
|
||||
Flutter 至少覆盖:
|
||||
|
||||
- 通知模型解析
|
||||
- 未读数展示逻辑
|
||||
- 列表点击后状态刷新
|
||||
- Realtime 事件驱动下的列表或 badge 更新逻辑
|
||||
|
||||
本阶段不要求测试:
|
||||
|
||||
- 推送送达率
|
||||
- 设备注册
|
||||
- 系统级离线推送
|
||||
|
||||
---
|
||||
|
||||
## 14. 后续扩展条件
|
||||
|
||||
只有在真实需求出现时,才继续扩展:
|
||||
|
||||
### 14.1 扩到更多表
|
||||
|
||||
出现以下需求之一时,再评估扩展到三张或四张表:
|
||||
|
||||
- 同一通知内容批量投递给大量用户
|
||||
- 需要模板复用
|
||||
- 需要设备级投递状态追踪
|
||||
- 需要运营后台批量发送
|
||||
|
||||
届时再评估是否新增:
|
||||
|
||||
- `user_push_devices`
|
||||
- `notification_push_attempts`
|
||||
|
||||
### 14.2 接入系统级离线推送
|
||||
|
||||
只有在确认以下需求时才接入:
|
||||
|
||||
- App 在后台或离线时也要触达用户
|
||||
- iOS / Android 需要真正弹出系统通知
|
||||
|
||||
届时再补:
|
||||
|
||||
- 设备 token 注册
|
||||
- APNs / FCM 配置
|
||||
- 推送发送服务
|
||||
- 失败重试和审计链路
|
||||
@@ -1,514 +0,0 @@
|
||||
# 静态通知配置同步计划
|
||||
|
||||
> 更新时间:2026-04-10
|
||||
> 状态:最终执行版
|
||||
|
||||
## 1. 目标
|
||||
|
||||
为通知系统增加一条独立的“静态配置 -> 数据库同步”链路,使服务端可以从仓库内的通知配置文件读取通知定义,并将其注册、更新或撤销到数据库。
|
||||
|
||||
本计划解决的问题:
|
||||
|
||||
- 通过静态文件维护系统通知内容
|
||||
- 手动触发后端读取并同步通知到数据库
|
||||
- 支持已有通知的修改
|
||||
- 支持已有通知的撤销
|
||||
- 保持用户侧已读状态不因通知内容更新而丢失
|
||||
|
||||
本计划不替代主通知系统计划,而是在其基础上增加“静态通知同步”能力。
|
||||
|
||||
关联文档:
|
||||
|
||||
- `docs/plans/notification-system-plan.md`
|
||||
|
||||
---
|
||||
|
||||
## 2. 范围
|
||||
|
||||
### 2.1 In Scope
|
||||
|
||||
- 新增静态通知配置目录
|
||||
- 定义静态通知 YAML 协议
|
||||
- 定义对应的 Pydantic schema
|
||||
- 实现后端扫描、校验、upsert 同步逻辑
|
||||
- 实现对主通知的修改和撤销
|
||||
- 新增手动触发同步脚本
|
||||
|
||||
### 2.2 Out of Scope
|
||||
|
||||
- 系统级离线推送
|
||||
- 自动监听文件变化并实时同步
|
||||
- 复杂运营后台
|
||||
|
||||
---
|
||||
|
||||
## 3. 现有代码基线
|
||||
|
||||
当前仓库已经有可直接复用的“静态配置 -> 数据库初始化”模式:
|
||||
|
||||
- 静态配置目录:`backend/src/core/config/static/database/`
|
||||
- 现有 YAML:
|
||||
- `llm_catalog.yaml`
|
||||
- `system_agents.yaml`
|
||||
- 现有加载与校验:`backend/src/core/config/initial/init_data.py`
|
||||
- 现有 CLI:`backend/src/core/runtime/cli.py`
|
||||
- 现有脚本:`infra/scripts/dev-migrate.sh`
|
||||
|
||||
通知同步应复用这套模式的核心思路:
|
||||
|
||||
- YAML 文件作为配置源
|
||||
- Pydantic schema 做强校验
|
||||
- 后端显式执行同步
|
||||
- 数据库使用 upsert 语义更新
|
||||
|
||||
但通知同步不应直接并入 `init-data/bootstrap` 默认流程,因为通知内容属于持续变更的数据,不是纯启动种子数据。
|
||||
|
||||
---
|
||||
|
||||
## 4. 目录设计
|
||||
|
||||
建议新增静态通知目录:
|
||||
|
||||
```text
|
||||
backend/src/core/config/static/notification/
|
||||
└── notifications/
|
||||
├── welcome_bonus.yaml
|
||||
├── maintenance_2026_04.yaml
|
||||
└── ...
|
||||
```
|
||||
|
||||
第一阶段不增加总索引文件,直接扫描 `notifications/*.yaml`。
|
||||
|
||||
原因:
|
||||
|
||||
- 少一层维护成本
|
||||
- 避免“文件内容”和“索引文件”双源不一致
|
||||
- 更适合增量增加通知文件
|
||||
|
||||
---
|
||||
|
||||
## 5. 数据模型变更
|
||||
|
||||
要支持“静态文件和数据库中的同一条通知”建立稳定映射,`notifications` 表需要增加来源标识字段。
|
||||
|
||||
建议新增字段:
|
||||
|
||||
- `source`
|
||||
- `source_key`
|
||||
- `source_version`
|
||||
- `content_hash`
|
||||
|
||||
建议约束:
|
||||
|
||||
- `UNIQUE(source, source_key)`
|
||||
|
||||
### 5.1 字段职责
|
||||
|
||||
- `source`
|
||||
- 通知来源
|
||||
- 当前静态通知固定为 `static`
|
||||
- `source_key`
|
||||
- 静态通知唯一键
|
||||
- 例如 `welcome_bonus`
|
||||
- 用于可靠 upsert
|
||||
- `source_version`
|
||||
- 配置版本号
|
||||
- 用于审计和变更追踪
|
||||
- `content_hash`
|
||||
- 标准化内容摘要
|
||||
- 用于判断文件内容是否发生变化
|
||||
|
||||
### 5.2 推荐表结构补充
|
||||
|
||||
在 `notifications` 表基础上补充:
|
||||
|
||||
```sql
|
||||
ALTER TABLE notifications
|
||||
ADD COLUMN source VARCHAR(32) NOT NULL DEFAULT 'manual',
|
||||
ADD COLUMN source_key VARCHAR(128),
|
||||
ADD COLUMN source_version INTEGER,
|
||||
ADD COLUMN content_hash VARCHAR(64);
|
||||
|
||||
CREATE UNIQUE INDEX uq_notifications_source_source_key
|
||||
ON notifications(source, source_key)
|
||||
WHERE source_key IS NOT NULL;
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- `manual` 可作为非静态创建通知的默认来源
|
||||
- 静态同步通知统一使用 `source='static'`
|
||||
|
||||
---
|
||||
|
||||
## 6. 静态通知 YAML 协议
|
||||
|
||||
每个 YAML 文件描述一条主通知及其投递目标。
|
||||
|
||||
推荐结构:
|
||||
|
||||
```yaml
|
||||
notification:
|
||||
source_key: welcome_bonus
|
||||
version: 1
|
||||
type: system
|
||||
status: published
|
||||
published_at: 2026-04-10T08:00:00Z
|
||||
|
||||
title: 新用户欢迎通知
|
||||
body: 你已获得注册奖励,可前往积分中心查看。
|
||||
|
||||
payload:
|
||||
deleted: false
|
||||
action: open_route
|
||||
route: /points
|
||||
entity_id: null
|
||||
tab: balance
|
||||
|
||||
targets:
|
||||
mode: all_users
|
||||
```
|
||||
|
||||
指定用户示例:
|
||||
|
||||
```yaml
|
||||
notification:
|
||||
source_key: maintenance_2026_04
|
||||
version: 3
|
||||
type: system
|
||||
status: published
|
||||
title: 系统维护通知
|
||||
body: 今晚 23:00 到 23:30 进行维护。
|
||||
payload:
|
||||
action: none
|
||||
|
||||
targets:
|
||||
mode: user_ids
|
||||
user_ids:
|
||||
- 11111111-1111-1111-1111-111111111111
|
||||
- 22222222-2222-2222-2222-222222222222
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Pydantic Schema 设计
|
||||
|
||||
静态通知文件必须先经过强校验,不能直接把 YAML 转 dict 入库。
|
||||
|
||||
建议新增模块:
|
||||
|
||||
- `backend/src/core/config/notification/static_schema.py`
|
||||
|
||||
建议 schema:
|
||||
|
||||
- `StaticNotificationDefinition`
|
||||
- `StaticNotificationTargets`
|
||||
- `StaticNotificationFile`
|
||||
|
||||
`payload` 不重新定义,直接复用现有通知协议里的 schema:
|
||||
|
||||
- `NotificationPayloadNone`
|
||||
- `NotificationPayloadRoute`
|
||||
- `NotificationPayloadUrl`
|
||||
|
||||
### 7.1 `StaticNotificationDefinition` 职责
|
||||
|
||||
- `source_key`
|
||||
- 静态通知唯一键
|
||||
- `version`
|
||||
- 配置版本号
|
||||
- `type`
|
||||
- 通知类型,当前默认 `system`
|
||||
- `status`
|
||||
- `draft/published/revoked`
|
||||
- `deleted`
|
||||
- 显式软删除主通知
|
||||
- `published_at`
|
||||
- 发布时间
|
||||
- `title/body/payload`
|
||||
- 通知内容
|
||||
|
||||
### 7.2 `StaticNotificationTargets` 职责
|
||||
|
||||
- `mode`
|
||||
- `all_users` 或 `user_ids`
|
||||
- `user_ids`
|
||||
- 仅当 `mode='user_ids'` 时允许
|
||||
|
||||
### 7.3 校验约束
|
||||
|
||||
- `source_key` 必填且全局唯一
|
||||
- `version >= 1`
|
||||
- `status` 只允许 `draft/published/revoked`
|
||||
- `deleted` 为可选布尔值
|
||||
- `payload` 必须符合现有通知 payload schema
|
||||
- `targets.mode='all_users'` 时不允许传 `user_ids`
|
||||
- `targets.mode='user_ids'` 时 `user_ids` 必填且不能为空
|
||||
|
||||
---
|
||||
|
||||
## 8. 同步语义
|
||||
|
||||
### 8.1 新建
|
||||
|
||||
当数据库中不存在 `(source='static', source_key=...)` 时:
|
||||
|
||||
1. 创建 `notifications`
|
||||
2. 按目标规则写入 `user_notifications`
|
||||
|
||||
### 8.2 修改
|
||||
|
||||
当数据库中已存在同一 `source_key` 时:
|
||||
|
||||
1. 更新 `notifications.title/body/payload/status/published_at/source_version/content_hash`
|
||||
2. 保留已有 `user_notifications`
|
||||
3. 不重置 `is_read/read_at`
|
||||
|
||||
这是强规则:
|
||||
|
||||
- 修改主通知内容,不影响用户已读状态
|
||||
|
||||
### 8.3 撤销
|
||||
|
||||
当 YAML 中:
|
||||
|
||||
- `notification.status = revoked`
|
||||
|
||||
则同步时:
|
||||
|
||||
1. 更新 `notifications.status='revoked'`
|
||||
2. 写入 `revoked_at`
|
||||
3. 不删除 `user_notifications`
|
||||
|
||||
### 8.4 统一删除
|
||||
|
||||
本阶段支持两种明确的下线方式:
|
||||
|
||||
1. 在 YAML 中显式写 `deleted: true`
|
||||
2. 执行同步时使用 `--prune`,将文件中已不存在的静态通知软删除
|
||||
|
||||
- `deleted: true` 语义:
|
||||
|
||||
- 设置 `notifications.deleted_at`
|
||||
- 不删除既有 `user_notifications`
|
||||
|
||||
- `--prune` 语义:
|
||||
|
||||
- 扫描范围内缺失的静态通知会被软删除
|
||||
- 不会删除非 `source='static'` 的通知
|
||||
|
||||
默认情况下,不因为文件消失自动删库。
|
||||
|
||||
原因:
|
||||
|
||||
- 文件误删风险高
|
||||
- 容易把版本控制操作误解释为业务删除
|
||||
|
||||
如果只是想临时停止用户可见,优先用:
|
||||
|
||||
- `status: revoked`
|
||||
|
||||
如果想做统一下线并保留审计主记录,可用:
|
||||
|
||||
- `deleted: true`
|
||||
|
||||
### 8.5 目标用户变更
|
||||
|
||||
默认采用保守策略:
|
||||
|
||||
- 新增目标用户时,补插入 `user_notifications`
|
||||
- 被移出目标集合的用户,不自动删除既有 `user_notifications`
|
||||
|
||||
原因:
|
||||
|
||||
- 防止误操作删除已投递历史
|
||||
- 与“通知一旦发出就保留用户侧记录”的语义更一致
|
||||
|
||||
如果执行同步时显式加上 `--reconcile-targets`,则:
|
||||
|
||||
- 当前目标集合之外的既有 `user_notifications` 会被删除
|
||||
|
||||
---
|
||||
|
||||
## 9. 后端实现方案
|
||||
|
||||
### 9.1 模块位置
|
||||
|
||||
建议新增:
|
||||
|
||||
```text
|
||||
backend/src/core/config/notification/
|
||||
├── static_schema.py
|
||||
└── static_sync.py
|
||||
```
|
||||
|
||||
不建议把通知同步继续堆进 `core/config/initial/init_data.py`。
|
||||
|
||||
原因:
|
||||
|
||||
- `init_data.py` 当前更适合 bootstrap seed
|
||||
- 通知同步是持续执行的配置同步任务
|
||||
- 语义上应独立
|
||||
|
||||
### 9.2 组件职责
|
||||
|
||||
- `static_schema.py`
|
||||
- 定义 YAML 文件的 Pydantic schema
|
||||
- `static_sync.py`
|
||||
- 扫描目录
|
||||
- 读取 YAML
|
||||
- 校验 schema
|
||||
- 计算差异
|
||||
- 执行 upsert
|
||||
|
||||
现有通知模块中建议补充内部同步能力:
|
||||
|
||||
- `v1/notifications/repository.py`
|
||||
- 补充按 `source/source_key` 查询与 upsert
|
||||
- `v1/notifications/service.py`
|
||||
- 补充内部同步逻辑与事务边界
|
||||
|
||||
### 9.3 日志与错误
|
||||
|
||||
遵循现有后端规则:
|
||||
|
||||
- 使用 `core.logging`
|
||||
- 不使用 `print`
|
||||
- YAML 校验失败要明确报错并中止
|
||||
- 数据库 upsert 失败要中止,不吞错
|
||||
|
||||
---
|
||||
|
||||
## 10. CLI 与脚本方案
|
||||
|
||||
### 10.1 后端 CLI
|
||||
|
||||
在 `backend/src/core/runtime/cli.py` 中新增命令:
|
||||
|
||||
- `sync-notifications`
|
||||
|
||||
建议调用方式:
|
||||
|
||||
```bash
|
||||
PYTHONPATH=backend/src uv run python -m core.runtime.cli sync-notifications
|
||||
```
|
||||
|
||||
建议参数:
|
||||
|
||||
- `--path`
|
||||
- `--source-key`
|
||||
- `--dry-run`
|
||||
- `--prune`
|
||||
- `--reconcile-targets`
|
||||
|
||||
危险行为必须显式开启,不默认启用。
|
||||
|
||||
### 10.2 infra 脚本
|
||||
|
||||
新增:
|
||||
|
||||
```text
|
||||
infra/scripts/register-notifications.sh
|
||||
```
|
||||
|
||||
脚本风格复用 `infra/scripts/dev-migrate.sh`:
|
||||
|
||||
- 读取 `.env`
|
||||
- 通过 `uv run python -m core.runtime.cli sync-notifications` 调用后端 CLI
|
||||
|
||||
建议用法:
|
||||
|
||||
```bash
|
||||
./infra/scripts/register-notifications.sh
|
||||
./infra/scripts/register-notifications.sh --dry-run
|
||||
./infra/scripts/register-notifications.sh --source-key welcome_bonus
|
||||
./infra/scripts/register-notifications.sh --prune --reconcile-targets
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. 与现有通知系统的关系
|
||||
|
||||
这条静态同步链路只负责:
|
||||
|
||||
- 把 YAML 中的通知定义注册到数据库
|
||||
- 更新通知主记录
|
||||
- 撤销通知主记录
|
||||
- 为目标用户补齐接收关系
|
||||
|
||||
它不替代现有通知 API:
|
||||
|
||||
- 用户列表、未读数、已读接口仍走现有通知系统
|
||||
- Flutter 端仍然从现有通知 API 和 Realtime 获取数据
|
||||
|
||||
如果通知内容被静态同步更新,而前台需要即时看到变更,建议在 Realtime 中补充:
|
||||
|
||||
- `notification_updated`
|
||||
|
||||
否则前台只能在下次 HTTP 拉取时看到更新后的内容。
|
||||
|
||||
---
|
||||
|
||||
## 12. 实施清单
|
||||
|
||||
1. 为 `notifications` 表增加 `source/source_key/source_version/content_hash`
|
||||
2. 增加 `(source, source_key)` 唯一约束
|
||||
3. 新增 `backend/src/core/config/static/notification/notifications/` 目录
|
||||
4. 定义静态通知 YAML 的 Pydantic schema
|
||||
5. 实现 YAML 扫描、加载、校验与 upsert 同步逻辑
|
||||
6. 为通知模块补充按 `source/source_key` 查询与更新能力
|
||||
7. 在 `core.runtime.cli` 中新增 `sync-notifications` 命令
|
||||
8. 新增 `infra/scripts/register-notifications.sh`
|
||||
9. 支持 `--prune` 和 `--reconcile-targets`
|
||||
10. 视需要补充 `notification_updated` Realtime 事件
|
||||
11. 编写最小测试和 dry-run 校验
|
||||
|
||||
---
|
||||
|
||||
## 13. 验收标准
|
||||
|
||||
- [ ] 新增一个 YAML 文件后,可成功同步出对应主通知记录
|
||||
- [ ] 相同 `source_key` 的 YAML 再次同步时,会更新主通知而不是插入重复记录
|
||||
- [ ] 修改 `title/body/payload` 后,再同步可反映到数据库
|
||||
- [ ] 用户侧已读状态在主通知内容更新后保持不变
|
||||
- [ ] 将 `status` 改为 `revoked` 后,再同步可使通知在用户列表中失效
|
||||
- [ ] 将 `deleted` 改为 `true` 后,再同步可使通知从用户列表和未读数中消失
|
||||
- [ ] `--dry-run` 可输出计划变更而不写库
|
||||
- [ ] `--prune` 可将文件中已不存在的静态通知软删除
|
||||
- [ ] `--reconcile-targets` 可严格对齐目标用户集合
|
||||
- [ ] YAML 结构不合法时同步失败,并给出明确错误
|
||||
- [ ] 脚本可按全量或按 `source_key` 手动触发同步
|
||||
|
||||
---
|
||||
|
||||
## 14. 测试要求
|
||||
|
||||
后端至少覆盖:
|
||||
|
||||
- YAML schema 校验
|
||||
- 新建通知同步
|
||||
- 已有通知更新同步
|
||||
- 撤销同步
|
||||
- 显式软删除同步
|
||||
- 相同 `source_key` 幂等 upsert
|
||||
- 更新主通知时不重置 `user_notifications.is_read/read_at`
|
||||
- 新增目标用户时补插入接收关系
|
||||
- 被移出目标集合时不删除既有接收关系
|
||||
- `--reconcile-targets` 下删除多余接收关系
|
||||
- `--prune` 下软删除缺失静态通知
|
||||
|
||||
脚本至少验证:
|
||||
|
||||
- 正常执行 CLI
|
||||
- `--dry-run` 不写库
|
||||
- `--source-key` 只同步指定通知
|
||||
|
||||
---
|
||||
|
||||
## 15. 后续扩展条件
|
||||
|
||||
只有在真实需求出现时,再考虑:
|
||||
|
||||
- 用删除文件触发软删除
|
||||
- 通过后台页面管理静态通知
|
||||
- 将静态通知同步纳入更完整的发布工作流
|
||||
@@ -98,7 +98,7 @@ Protocol verification status:
|
||||
### register_bonus_claims
|
||||
|
||||
- PK: `id`
|
||||
- Core fields: `email_hash`, `user_email_snapshot`, `first_user_id_snapshot`, `balance_snapshot`, `grant_event_id`, `created_at`, `updated_at`
|
||||
- Core fields: `email_hash`, `user_email_snapshot`, `first_user_id_snapshot`, `balance_snapshot`, `grant_event_id`, `has_purchased_starter_pack`, `created_at`, `updated_at`
|
||||
- Constraints:
|
||||
- `email_hash` unique
|
||||
- `grant_event_id` unique
|
||||
@@ -106,6 +106,7 @@ Protocol verification status:
|
||||
- `email_hash` must be HMAC-SHA256 over normalized email (`trim + lower`)
|
||||
- key source: backend config `points_policy.register_bonus_hmac_key`
|
||||
- `balance_snapshot` stores the latest pre-delete account balance for same-email re-registration recovery
|
||||
- `has_purchased_starter_pack` tracks whether user has purchased the starter pack ($0.99/60 credits)
|
||||
|
||||
#### points_ledger.metadata (schema_version=1)
|
||||
|
||||
@@ -206,3 +207,93 @@ Managed by `python -m core.runtime.cli sync-notifications [flags]`:
|
||||
- `--source-key <key>` — sync only the notification with the matching `source_key`
|
||||
|
||||
Run after migrations on fresh environments or after adding new notification YAML definitions. Not included in `bootstrap` to keep bootstrap fast and free of unintended side effects.
|
||||
|
||||
## Packages API
|
||||
|
||||
### GET /api/v1/points/packages
|
||||
|
||||
Returns available purchase packages for the current user's region, including starter pack eligibility.
|
||||
|
||||
**Request:**
|
||||
- Auth: Required (JWT)
|
||||
- Headers: `Authorization: Bearer <token>`
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"region": "US",
|
||||
"currency": "USD",
|
||||
"packages": [
|
||||
{
|
||||
"productCode": "new_user_pack_099_60",
|
||||
"type": "starter",
|
||||
"priceUsd": "0.99",
|
||||
"credits": 60,
|
||||
"badge": null,
|
||||
"isStarter": true,
|
||||
"starterEligible": true,
|
||||
"sortOrder": 0
|
||||
},
|
||||
{
|
||||
"productCode": "basic_pack_499_100",
|
||||
"type": "regular",
|
||||
"priceUsd": "4.99",
|
||||
"credits": 100,
|
||||
"badge": null,
|
||||
"isStarter": false,
|
||||
"starterEligible": false,
|
||||
"sortOrder": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Fields:**
|
||||
- `region`: ISO 3166-1 alpha-2 country code (e.g., "US", "CN")
|
||||
- `currency`: ISO 4217 currency code (e.g., "USD")
|
||||
- `packages`: List of available packages
|
||||
- `productCode`: Unique product identifier
|
||||
- `type`: "starter" (new user pack) or "regular"
|
||||
- `priceUsd`: Price in USD (decimal string)
|
||||
- `credits`: Number of credits
|
||||
- `badge`: Optional badge text (e.g., "Popular")
|
||||
- `isStarter`: Whether this is a starter pack
|
||||
- `starterEligible`: Whether user is eligible to purchase starter pack
|
||||
- `sortOrder`: Display order (ascending)
|
||||
|
||||
**Business Logic:**
|
||||
1. Determine user's region from `profile.settings.preferences.country` (default: "US")
|
||||
2. Load package configuration from `backend/src/core/config/static/packages/{country}.yaml` (fallback to `default.yaml`)
|
||||
3. Check starter pack eligibility:
|
||||
- If `register_bonus_claims.has_purchased_starter_pack = true`, exclude starter pack from response
|
||||
- Otherwise, include starter pack with `starterEligible: true`
|
||||
|
||||
**Configuration Files:**
|
||||
- Path: `backend/src/core/config/static/packages/`
|
||||
- Format: YAML
|
||||
- Example: `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
|
||||
```
|
||||
|
||||
**Country/Region Codes:**
|
||||
- Uses ISO 3166-1 alpha-2 standard
|
||||
- Default: `US` (United States)
|
||||
- Examples: `CN` (China), `TW` (Taiwan), `HK` (Hong Kong), `JP` (Japan)
|
||||
|
||||
Reference in New Issue
Block a user