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:
qzl
2026-04-16 16:11:09 +08:00
parent 443c0c80ae
commit ff40ff9dd8
38 changed files with 1434 additions and 2517 deletions
@@ -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": "前端 UICoinCenterScreen 动态渲染套餐",
"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
}
}
+4 -12
View File
@@ -8,7 +8,7 @@
<!-- @@@auto:current-status --> <!-- @@@auto:current-status -->
- **Active File**: `journal-1.md` - **Active File**: `journal-1.md`
- **Total Sessions**: 9 - **Total Sessions**: 1
- **Last Active**: 2026-04-16 - **Last Active**: 2026-04-16
<!-- @@@/auto:current-status --> <!-- @@@/auto:current-status -->
@@ -19,7 +19,7 @@
<!-- @@@auto:active-documents --> <!-- @@@auto:active-documents -->
| File | Lines | Status | | File | Lines | Status |
|------|-------|--------| |------|-------|--------|
| `journal-1.md` | ~477 | Active | | `journal-1.md` | ~80 | Active |
<!-- @@@/auto:active-documents --> <!-- @@@/auto:active-documents -->
--- ---
@@ -29,21 +29,13 @@
<!-- @@@auto:session-history --> <!-- @@@auto:session-history -->
| # | Date | Title | Commits | | # | Date | Title | Commits |
|---|------|-------|---------| |---|------|-------|---------|
| 9 | 2026-04-16 | 起卦教程首次访问追踪 + Agent时间上下文 | `69b34bd` | | 1 | 2026-04-16 | 新人初始礼包购买追踪功能 - 计划制定 | - |
| 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` |
<!-- @@@/auto:session-history --> <!-- @@@/auto:session-history -->
--- ---
## Notes ## Notes
- Worktree workspace for: `worktree/feat-starter-package-purchase-tracking`
- Sessions are appended to journal files - Sessions are appended to journal files
- New journal file created when current exceeds 2000 lines - New journal file created when current exceeds 2000 lines
- Use `add_session.py` to record sessions
+66 -459
View File
@@ -1,477 +1,84 @@
# Journal - zl-q (Part 1) # Journal - zl-q
> AI development session journal > Development session records for worktree: feat-starter-package-purchase-tracking
> Started: 2026-04-10
--- ---
## Session 1: 新人初始礼包购买追踪功能 - 计划制定
## 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时间上下文
**Date**: 2026-04-16 **Date**: 2026-04-16
**Task**: 起卦教程首次访问追踪 + Agent时间上下文 **Task**: starter-package-purchase-tracking
### Summary ### 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) | | `.trellis/tasks/04-16-starter-package-purchase-tracking/task.json` | 任务配置 |
| `.trellis/tasks/04-16-starter-package-purchase-tracking/prd.md` | 需求与实现计划 |
### Testing
- [OK] (Add test results)
### Status
[OK] **Completed**
### Next Steps ### 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', aiLanguage: (preferencesRaw['ai_language'] as String?) ?? 'zh-CN',
timezone: timezone:
(preferencesRaw['timezone'] as String?) ?? 'Asia/Shanghai', (preferencesRaw['timezone'] as String?) ?? 'Asia/Shanghai',
country: (preferencesRaw['country'] as String?) ?? 'CN', country: (preferencesRaw['country'] as String?) ?? 'US',
) )
: const PreferenceSettings(); : const PreferenceSettings();
@@ -16,7 +16,7 @@ class PreferenceSettings {
this.interfaceLanguage = 'zh-CN', this.interfaceLanguage = 'zh-CN',
this.aiLanguage = 'zh-CN', this.aiLanguage = 'zh-CN',
this.timezone = 'Asia/Shanghai', this.timezone = 'Asia/Shanghai',
this.country = 'CN', this.country = 'US',
}); });
final String interfaceLanguage; final String interfaceLanguage;
@@ -1,15 +1,66 @@
import 'package:flutter/material.dart'; 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 '../../../../l10n/app_localizations.dart';
import '../../../../shared/theme/app_color_palette.dart'; import '../../../../shared/theme/app_color_palette.dart';
import '../../../../shared/theme/design_tokens.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'; import '../widgets/settings_section_widgets.dart';
class CoinCenterScreen extends StatelessWidget { class CoinCenterScreen extends StatefulWidget {
const CoinCenterScreen({super.key, required this.balance}); const CoinCenterScreen({super.key, required this.balance});
final int 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context)!;
@@ -52,7 +103,7 @@ class CoinCenterScreen extends StatelessWidget {
), ),
const SizedBox(height: AppSpacing.xs), const SizedBox(height: AppSpacing.xs),
Text( Text(
l10n.settingsCoinBalanceValue(balance), l10n.settingsCoinBalanceValue(widget.balance),
style: Theme.of( style: Theme.of(
context, context,
).textTheme.headlineMedium?.copyWith(color: colors.onPrimary), ).textTheme.headlineMedium?.copyWith(color: colors.onPrimary),
@@ -69,26 +120,55 @@ class CoinCenterScreen extends StatelessWidget {
), ),
const SizedBox(height: AppSpacing.xl), const SizedBox(height: AppSpacing.xl),
SectionLabel(text: l10n.settingsCoinRechargeSection), SectionLabel(text: l10n.settingsCoinRechargeSection),
CoinPackageCard( ..._buildPackageCards(l10n),
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,
),
], ],
), ),
); );
} }
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,
};
}
} }
+1
View File
@@ -190,6 +190,7 @@
}, },
"settingsCoinCenterDescription": "", "settingsCoinCenterDescription": "",
"settingsCoinRechargeSection": "Recharge Packages", "settingsCoinRechargeSection": "Recharge Packages",
"settingsCoinPackStarter": "New User Pack",
"settingsCoinPackBasic": "Starter Pack", "settingsCoinPackBasic": "Starter Pack",
"settingsCoinPackPopular": "Popular Pack", "settingsCoinPackPopular": "Popular Pack",
"settingsCoinPackPremium": "Premium Pack", "settingsCoinPackPremium": "Premium Pack",
+6
View File
@@ -939,6 +939,12 @@ abstract class AppLocalizations {
/// **'充值套餐'** /// **'充值套餐'**
String get settingsCoinRechargeSection; String get settingsCoinRechargeSection;
/// No description provided for @settingsCoinPackStarter.
///
/// In zh, this message translates to:
/// **'新人专享包'**
String get settingsCoinPackStarter;
/// No description provided for @settingsCoinPackBasic. /// No description provided for @settingsCoinPackBasic.
/// ///
/// In zh, this message translates to: /// In zh, this message translates to:
+3
View File
@@ -469,6 +469,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get settingsCoinRechargeSection => 'Recharge Packages'; String get settingsCoinRechargeSection => 'Recharge Packages';
@override
String get settingsCoinPackStarter => 'New User Pack';
@override @override
String get settingsCoinPackBasic => 'Starter Pack'; String get settingsCoinPackBasic => 'Starter Pack';
+6
View File
@@ -454,6 +454,9 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String get settingsCoinRechargeSection => '充值套餐'; String get settingsCoinRechargeSection => '充值套餐';
@override
String get settingsCoinPackStarter => '新人专享包';
@override @override
String get settingsCoinPackBasic => '入门补充包'; String get settingsCoinPackBasic => '入门补充包';
@@ -1460,6 +1463,9 @@ class AppLocalizationsZhHant extends AppLocalizationsZh {
@override @override
String get settingsCoinRechargeSection => '儲值套餐'; String get settingsCoinRechargeSection => '儲值套餐';
@override
String get settingsCoinPackStarter => '新人專享包';
@override @override
String get settingsCoinPackBasic => '入門補充包'; String get settingsCoinPackBasic => '入門補充包';
+1
View File
@@ -190,6 +190,7 @@
}, },
"settingsCoinCenterDescription": "", "settingsCoinCenterDescription": "",
"settingsCoinRechargeSection": "充值套餐", "settingsCoinRechargeSection": "充值套餐",
"settingsCoinPackStarter": "新人专享包",
"settingsCoinPackBasic": "入门补充包", "settingsCoinPackBasic": "入门补充包",
"settingsCoinPackPopular": "常用加量包", "settingsCoinPackPopular": "常用加量包",
"settingsCoinPackPremium": "高频进阶包", "settingsCoinPackPremium": "高频进阶包",
+1
View File
@@ -123,6 +123,7 @@
}, },
"settingsCoinCenterDescription": "", "settingsCoinCenterDescription": "",
"settingsCoinRechargeSection": "儲值套餐", "settingsCoinRechargeSection": "儲值套餐",
"settingsCoinPackStarter": "新人專享包",
"settingsCoinPackBasic": "入門補充包", "settingsCoinPackBasic": "入門補充包",
"settingsCoinPackPopular": "常用加量包", "settingsCoinPackPopular": "常用加量包",
"settingsCoinPackPremium": "高頻進階包", "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")
+3 -9
View File
@@ -15,6 +15,7 @@ from core.logging import get_logger
from models.llm import Llm from models.llm import Llm
from models.llm_factory import LlmFactory from models.llm_factory import LlmFactory
from models.system_agents import SystemAgents 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") logger = get_logger("core.config.initial.init_data")
@@ -48,9 +49,7 @@ class SystemAgentsYaml(BaseModel):
def _default_catalog_path() -> Path: def _default_catalog_path() -> Path:
return ( return get_llm_catalog_config_path()
Path(__file__).resolve().parents[1] / "static" / "database" / "llm_catalog.yaml"
)
def load_llm_catalog(catalog_path: Path | None = None) -> dict[str, Any]: 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: def _default_system_agents_path() -> Path:
return ( return get_system_agents_config_path()
Path(__file__).resolve().parents[1]
/ "static"
/ "database"
/ "system_agents.yaml"
)
def load_system_agents(catalog_path: Path | None = None) -> dict[str, Any]: 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.auth_user import AuthUser
from models.notification import Notification from models.notification import Notification
from models.user_notification import UserNotification from models.user_notification import UserNotification
from utils.paths import get_notification_config_dir
logger = get_logger("core.config.notification.static_sync") logger = get_logger("core.config.notification.static_sync")
@@ -41,12 +42,7 @@ class StaticNotificationSyncResult:
def default_static_notification_path() -> Path: def default_static_notification_path() -> Path:
return ( return get_notification_config_dir()
Path(__file__).resolve().parents[1]
/ "static"
/ "notification"
/ "notifications"
)
def load_static_notification_documents( 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 import json
from pathlib import Path from pathlib import Path
from utils.paths import get_gua_catalog_path
@dataclass(frozen=True) @dataclass(frozen=True)
class GuaCatalogItem: class GuaCatalogItem:
@@ -24,8 +26,7 @@ class GuaCatalogItem:
def _resolve_catalog_file() -> Path: def _resolve_catalog_file() -> Path:
current = Path(__file__).resolve() target = get_gua_catalog_path()
target = current.parent / "data/gua_catalog.json"
if not target.exists(): if not target.exists():
raise FileNotFoundError(f"gua_catalog.json not found: {target}") raise FileNotFoundError(f"gua_catalog.json not found: {target}")
return target return target
+4 -1
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
import uuid 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.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column 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) balance_snapshot: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
grant_event_id: Mapped[str] = mapped_column(String(64), nullable=False) grant_event_id: Mapped[str] = mapped_column(String(64), nullable=False)
has_purchased_starter_pack: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False
)
+1 -1
View File
@@ -14,7 +14,7 @@ class PreferenceSettings(BaseModel):
interface_language: str = "zh-CN" interface_language: str = "zh-CN"
ai_language: str = "zh-CN" ai_language: str = "zh-CN"
timezone: str = "Asia/Shanghai" timezone: str = "Asia/Shanghai"
country: str = "CN" country: str = "US"
@field_validator("interface_language", "ai_language") @field_validator("interface_language", "ai_language")
@classmethod @classmethod
+29
View File
@@ -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",
]
+51
View File
@@ -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"
+2 -8
View File
@@ -16,17 +16,11 @@ from schemas.agent.runtime_config import (
MessageContextConfig, MessageContextConfig,
RuntimeConfig, RuntimeConfig,
) )
from utils.paths import get_system_agents_config_path
def _default_system_agents_path() -> Path: def _default_system_agents_path() -> Path:
return ( return get_system_agents_config_path()
Path(__file__).resolve().parents[2]
/ "core"
/ "config"
/ "static"
/ "database"
/ "system_agents.yaml"
)
def _load_system_agents_yaml(path: Path | None = None) -> dict[str, object]: def _load_system_agents_yaml(path: Path | None = None) -> dict[str, object]:
+20
View File
@@ -10,6 +10,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from models.agent_chat_message import AgentChatMessage from models.agent_chat_message import AgentChatMessage
from models.points_audit_ledger import PointsAuditLedger from models.points_audit_ledger import PointsAuditLedger
from models.points_ledger import PointsLedger from models.points_ledger import PointsLedger
from models.profile import Profile
from models.register_bonus_claims import RegisterBonusClaims from models.register_bonus_claims import RegisterBonusClaims
from models.user_points import UserPoints from models.user_points import UserPoints
from schemas.domain.points import ( from schemas.domain.points import (
@@ -189,3 +190,22 @@ class PointsRepository:
claim.balance_snapshot = int(balance_snapshot) claim.balance_snapshot = int(balance_snapshot)
await self._session.flush() await self._session.flush()
return True 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
+29 -1
View File
@@ -6,7 +6,7 @@ from fastapi import APIRouter, Depends
from core.auth.models import CurrentUser from core.auth.models import CurrentUser
from v1.points.dependencies import get_points_service 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.points.service import PointsService
from v1.users.dependencies import get_current_user from v1.users.dependencies import get_current_user
@@ -26,3 +26,31 @@ async def get_points_balance(
runCost=result.run_cost, runCost=result.run_cost,
canRun=result.can_run, 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
],
)
+22
View File
@@ -1,5 +1,7 @@
from __future__ import annotations from __future__ import annotations
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field
@@ -11,3 +13,23 @@ class PointsBalanceResponse(BaseModel):
available_balance: int = Field(alias="availableBalance", ge=0) available_balance: int = Field(alias="availableBalance", ge=0)
run_cost: int = Field(alias="runCost", gt=0) run_cost: int = Field(alias="runCost", gt=0)
can_run: bool = Field(alias="canRun") 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]
+74 -1
View File
@@ -4,9 +4,13 @@ from dataclasses import dataclass
from decimal import Decimal from decimal import Decimal
import hashlib import hashlib
import hmac import hmac
from typing import Literal from typing import TYPE_CHECKING, Literal
from uuid import UUID, uuid4 from uuid import UUID, uuid4
from core.config.packages import (
PackageType,
get_packages_config_for_region,
)
from core.config.settings import config from core.config.settings import config
from core.http.errors import ApiProblemError, problem_payload from core.http.errors import ApiProblemError, problem_payload
from schemas.domain.points import ( from schemas.domain.points import (
@@ -18,8 +22,12 @@ from schemas.domain.points import (
) )
from schemas.enums import PointsBizType, PointsChangeType, PointsOperatorType from schemas.enums import PointsBizType, PointsChangeType, PointsOperatorType
from schemas.domain.points import ApplyPointsChangeCommand from schemas.domain.points import ApplyPointsChangeCommand
from schemas.shared.user import parse_profile_settings
from v1.points.repository import PointsRepository from v1.points.repository import PointsRepository
if TYPE_CHECKING:
pass
RUN_POINTS_COST = 20 RUN_POINTS_COST = 20
@@ -55,6 +63,24 @@ class RegisterBonusResult:
event_id: str 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: class PointsService:
def __init__(self, repository: PointsRepository) -> None: def __init__(self, repository: PointsRepository) -> None:
self._repository = repository self._repository = repository
@@ -408,3 +434,50 @@ class PointsService:
digestmod=hashlib.sha256, digestmod=hashlib.sha256,
) )
return digest.hexdigest() 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 回调处理能力。
-525
View File
@@ -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_points3-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%+**
-708
View File
@@ -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 配置
- 推送发送服务
- 失败重试和审计链路
-514
View File
@@ -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 ### register_bonus_claims
- PK: `id` - 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: - Constraints:
- `email_hash` unique - `email_hash` unique
- `grant_event_id` unique - `grant_event_id` unique
@@ -106,6 +106,7 @@ Protocol verification status:
- `email_hash` must be HMAC-SHA256 over normalized email (`trim + lower`) - `email_hash` must be HMAC-SHA256 over normalized email (`trim + lower`)
- key source: backend config `points_policy.register_bonus_hmac_key` - 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 - `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) #### 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` - `--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. 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)