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 -->
- **Active File**: `journal-1.md`
- **Total Sessions**: 9
- **Total Sessions**: 1
- **Last Active**: 2026-04-16
<!-- @@@/auto:current-status -->
@@ -19,7 +19,7 @@
<!-- @@@auto:active-documents -->
| File | Lines | Status |
|------|-------|--------|
| `journal-1.md` | ~477 | Active |
| `journal-1.md` | ~80 | Active |
<!-- @@@/auto:active-documents -->
---
@@ -29,21 +29,13 @@
<!-- @@@auto:session-history -->
| # | Date | Title | Commits |
|---|------|-------|---------|
| 9 | 2026-04-16 | 起卦教程首次访问追踪 + Agent时间上下文 | `69b34bd` |
| 8 | 2026-04-15 | Session deletion anonymization for iOS compliance | `c2b726e` |
| 7 | 2026-04-15 | 六爻算法修复 + Prompt架构重构 + i18n输出规则 | `9598d16`, `be68681` |
| 6 | 2026-04-13 | 修复追问链路与上限判定 | - |
| 5 | 2026-04-13 | feat: 邀请码显示功能 - 后端API + 前端对接 | - |
| 4 | 2026-04-13 | 绑定积分重注册余额恢复提交 | `c55be6d` |
| 3 | 2026-04-13 | 积分重注册余额恢复验证 | - |
| 2 | 2026-04-10 | 静态通知同步 + 积分审计 bug 修复 | `3f3d613` |
| 1 | 2026-04-10 | 实现站内通知系统 | `3f3d613` |
| 1 | 2026-04-16 | 新人初始礼包购买追踪功能 - 计划制定 | - |
<!-- @@@/auto:session-history -->
---
## Notes
- Worktree workspace for: `worktree/feat-starter-package-purchase-tracking`
- Sessions are appended to journal files
- New journal file created when current exceeds 2000 lines
- Use `add_session.py` to record sessions
+66 -459
View File
@@ -1,477 +1,84 @@
# Journal - zl-q (Part 1)
# Journal - zl-q
> AI development session journal
> Started: 2026-04-10
> Development session records for worktree: feat-starter-package-purchase-tracking
---
## Session 1: 实现站内通知系统
**Date**: 2026-04-10
**Task**: 实现站内通知系统
### Summary
(Add summary)
### Main Changes
## 完成内容
| 模块 | 描述 |
|------|------|
| 协议文档 | `docs/protocols/notification/notification-inbox-protocol.md``http-error-codes.md` 新增 `NOTIFICATION_NOT_FOUND` |
| 数据库迁移 | `notifications` + `user_notifications` 两张表, RLS 策略, 索引 |
| 后端 ORM | `Notification(TimestampMixin, SoftDeleteMixin, Base)` + `UserNotification(TimestampMixin, Base)` |
| 后端 API | schema/repository/service/router 全套, 4 个端点 (列表/未读数/单条已读/全部已读) |
| 后端测试 | 19 个单元测试覆盖: 列表权限隔离, 未读数统计, 幂等已读, 越权拒绝, 撤销/删除过滤, payload 解析 |
| Flutter models | `NotificationPayload` sealed class (none/open_route/open_url) + `NotificationItem` + `NotificationListResult` |
| Flutter API | `NotificationApi` (list/unreadCount/markRead/markAllRead) |
| Flutter Repository | 抽象接口 + `NotificationRepositoryImpl` |
| Flutter Bloc | `NotificationBloc` (ChangeNotifier) 含 Realtime 事件处理 |
| Flutter UI | `NotificationCenterScreen` + `NotificationListItem` + 首页 badge 集成 |
| Flutter 测试 | 14 个测试: payload 解析 6 个 + bloc 状态管理 8 个 |
## 验收标准对照
- [x] 能为指定用户写入一条站内通知 (ORM + migration 就绪)
- [x] 用户能看到自己的通知列表 (GET /notifications)
- [x] 用户点击通知后可标记为已读 (PATCH /notifications/{id}/read)
- [x] "全部已读"后未读数归零 (PATCH /notifications/mark-all-read)
- [x] 用户 A 不能读取或修改用户 B 的通知 (service 层 user_id 来自 JWT, 测试覆盖)
- [x] 已读接口重复调用不会报错 (幂等实现, 测试覆盖)
- [x] 首页 badge 会随未读数自动更新 (NotificationBloc + ListenableBuilder)
- [x] 撤销或统一删除主通知后, 用户侧列表不再展示 (repository 过滤 status+deleted_at)
### Git Commits
| Hash | Message |
|------|---------|
| `3f3d613` | (see git log) |
### Testing
- [OK] (Add test results)
### Status
[OK] **Completed**
### Next Steps
- None - task complete
## Session 2: 静态通知同步 + 积分审计 bug 修复
**Date**: 2026-04-10
**Task**: 静态通知同步 + 积分审计 bug 修复
### Summary
(Add summary)
### Main Changes
## 完成内容
| 功能 | 说明 |
|------|------|
| 静态通知 Pydantic Schema | `static_schema.py` — 含 `deleted` 字段支持显式软删除 |
| 静态通知同步逻辑 | `static_sync.py` — 创建、更新、撤销、软删除、prune、reconcile-targets |
| CLI 命令 | `sync-notifications` 支持 `--path/--source-key/--dry-run/--prune/--reconcile-targets` |
| 数据库迁移 | 新增 `source/source_key/source_version/content_hash` 字段 |
| 示例通知 | `welcome_points.yaml` |
| 触发脚本 | `infra/scripts/register-notifications.sh` |
| 协议文档 | `static-notification-sync-protocol.md` |
| Bug 修复 | `AuditLedgerMetadata` 未序列化直接写 JSONB → `.model_dump(mode="json")` |
| 单测 | 28 passed`test_static_notification_sync.py``test_notification_service.py` |
| 冒烟验证 | 注册→通知→未读数→reconcile-targets→prune 全链路通过 |
**未完成**:
- `notification_updated` Realtime 事件链路
- Flutter 端通知中心页面、badge、Realtime 订阅
**新增文件**:
- `backend/src/core/config/notification/__init__.py`
- `backend/src/core/config/notification/static_schema.py`
- `backend/src/core/config/notification/static_sync.py`
- `backend/src/core/config/static/notification/notifications/welcome_points.yaml`
- `backend/alembic/versions/20260411_0005_add_notification_static_sync_fields.py`
- `backend/tests/unit/test_static_notification_sync.py`
- `docs/protocols/notification/static-notification-sync-protocol.md`
- `infra/scripts/register-notifications.sh`
**修改文件**:
- `backend/src/core/runtime/cli.py`
- `backend/src/models/notification.py`
- `backend/src/v1/points/repository.py`
- `docs/plans/static-notification-sync-plan.md`
### Git Commits
| Hash | Message |
|------|---------|
| `3f3d613` | (see git log) |
### Testing
- [OK] (Add test results)
### Status
[OK] **Completed**
### Next Steps
- None - task complete
## Session 3: 积分重注册余额恢复验证
**Date**: 2026-04-13
**Task**: 积分重注册余额恢复验证
### Summary
完成 register_bonus_claims 快照方案的本地迁移与集成测试验证,确认删除账号后重注册可恢复删除前积分余额。
### Main Changes
| Feature | Description |
|---------|-------------|
| DB Migration | 通过 `dev-migrate.sh migrate` 应用 `20260413_0004_register_bonus_claims_snapshot`,新增 `first_user_id_snapshot``balance_snapshot`,移除 `first_user_id`。 |
| Restore Logic | 注册流程优先读取 `balance_snapshot` 恢复余额;删除账号前写入当前余额快照。 |
| Integration Tests | 新增未消费删号重注册恢复场景,并更新已有消费后删号重注册断言。 |
**Updated Files**:
- `backend/alembic/versions/20260413_0004_register_bonus_claims_snapshot.py`
- `backend/src/models/register_bonus_claims.py`
- `backend/src/v1/points/repository.py`
- `backend/src/v1/points/service.py`
- `backend/src/v1/users/service.py`
- `backend/tests/integration/test_register_run_delete_reregister.py`
- `backend/tests/unit/test_points_service_audit.py`
- `docs/protocols/common/user-points-chat-data-protocol.md`
**Verification**:
- `uv run pytest backend/tests/unit/test_points_service_audit.py` -> 5 passed
- `uv run pytest backend/tests/integration/test_register_run_delete_reregister.py` -> 2 passed
- Supabase MCP 查询确认 `register_bonus_claims.balance_snapshot` 已写入并与测试行为一致。
### Git Commits
(No commits - planning session)
### Testing
- [OK] (Add test results)
### Status
[OK] **Completed**
### Next Steps
- None - task complete
## Session 4: 绑定积分重注册余额恢复提交
**Date**: 2026-04-13
**Task**: 绑定积分重注册余额恢复提交
### Summary
将删除账号后重注册积分余额恢复 feature 的代码提交与 session 记录绑定。
### Main Changes
| Item | Details |
|------|---------|
| Feature Commit | `c55be6d` |
| Scope | register_bonus_claims 快照字段、删号前余额快照、重注册余额恢复、相关测试与协议更新 |
**Validation**:
- pre-commit hooks passed during commit
- integration: `backend/tests/integration/test_register_run_delete_reregister.py` passed (`2 passed`)
### Git Commits
| Hash | Message |
|------|---------|
| `c55be6d` | (see git log) |
### Testing
- [OK] (Add test results)
### Status
[OK] **Completed**
### Next Steps
- None - task complete
## Session 5: feat: 邀请码显示功能 - 后端API + 前端对接
**Date**: 2026-04-13
**Task**: feat: 邀请码显示功能 - 后端API + 前端对接
### Summary
(Add summary)
### Main Changes
## Backend 新增 (src/v1/invite/)
| 文件 | 描述 |
|------|------|
| schemas.py | `MyInviteCodeResponse` (code, used_count) |
| repository.py | `InviteCodeRepository.get_by_owner_id()` |
| service.py | `InviteCodeService.get_my_invite_code()` |
| dependencies.py | 依赖注入 |
| router.py | `GET /api/v1/invite/me` |
修改: `src/v1/router.py` - 注册 invite_router
## Frontend 新增/修改 (apps/lib/features/settings/)
新增:
- `data/models/my_invite_code.dart` - `MyInviteCode` 数据模型
- `data/apis/invite_api.dart` - API 调用
- `data/repositories/invite_repository.dart` - Repository 封装
修改:
- `invite_screen.dart` - 移除 mock 数据,改为调用真实 API,增加 loading/error 状态
- `settings_screen.dart` - 接收 `InviteRepository` 参数
- `home_screen.dart` - 创建并传递 `InviteRepository` 实例
**验证**: ruff check ✅ / flutter analyze ✅
### Git Commits
(No commits - planning session)
### Testing
- [OK] (Add test results)
### Status
[OK] **Completed**
### Next Steps
- None - task complete
## Session 6: 修复追问链路与上限判定
**Date**: 2026-04-13
**Task**: 修复追问链路与上限判定
### Summary
定位并修复 follow_up 上下文解析报错;将追问上限改为基于 assistant 回复数;补充单测与集成测试并通过。
### Main Changes
| 项目 | 说明 |
|------|------|
| 追问报错根因 | `worker-agent.log``AgentChatMessageMetadata` 校验失败,原因是历史 metadata 存在 snake_case 字段与 alias 契约不一致 |
| 结构修复 | `divination` 相关模型启用 `populate_by_name=True`,允许 snake_case/alias 一致解析;用户消息缓存写入统一 `by_alias=True` |
| 上限逻辑 | 会话运行上限由按 user 消息数改为按 assistant 消息数统计 |
| 回归测试 | 新增 `backend/tests/unit/test_runtime_context_messages.py` 覆盖 snake_case metadata 场景 |
| 集成测试 | 新增 `backend/tests/integration/test_follow_up_flow.py`,验证 chat->follow_up 成功、assistant=2 后再次 follow_up 返回 409 |
**验证结果**:
- `uv run ruff check`(相关文件)通过
- `uv run pytest backend/tests/unit/test_runtime_context_messages.py backend/tests/unit/test_runtime_models_worker_output.py backend/tests/unit/test_history_message_schema.py` 通过
- `./infra/scripts/app.sh restart` 后,`uv run pytest backend/tests/integration/test_follow_up_flow.py` 通过
### Git Commits
(No commits - planning session)
### Testing
- [OK] (Add test results)
### Status
[OK] **Completed**
### Next Steps
- None - task complete
## Session 7: 六爻算法修复 + Prompt架构重构 + i18n输出规则
**Date**: 2026-04-15
**Task**: 六爻算法修复 + Prompt架构重构 + i18n输出规则
### Summary
(Add summary)
### Main Changes
## 概述
本次会话完成了六爻核心算法的全面审计修复、prompt架构重构清理、以及多语言输出规则适配。
### 算法修复 (P0/P1)
| 问题 | 修复内容 |
|------|----------|
| P0-1 空亡判断 | 改为仅从日柱计算,年月空亡仅标注不断事 |
| P0-2 暗动逻辑 | 重写为静爻+旺相+日冲三条件 |
| P1-1 月破 | 独立标注 |
| P1-2 动不为空/旺不为空 | 补充判断 |
| P1-3 三合局 | 新增判断逻辑 |
| P1-4 反吟伏吟 | 新增判断逻辑 |
| P1-5 日辰十二长生 | 新增字段 `riChenZhangSheng` |
| P1-6 回头生克 | 新增判断逻辑 |
### Prompt架构重构
- **删除 `_build_env_section`**:不再向prompt泄露用户上下文(user_context、timezone、client_time等)
- **简化语言判断**:删除 `if is_chinese` 分支,`_LANGUAGE_LABELS` 已覆盖全部语言映射
- **安全规则改为六爻专属**:只回答六爻占卜相关问题,拒绝无关提问
- **`_WORKER_OUTPUT_RULES` 多语言适配**zh-CN/zh-Hant/en 三版本,按 `ai_language` 分发
- **`_WORKER_ROLE_PLAYING` 始终中文**:保证六爻专业性不受语言切换影响
- **`sign_level` 枚举统一**:所有语言版本强制使用简体中文枚举值(上上签/中上签/中下签/下下签),前端负责显示映射
- **`worker_rules.py` 独立文件**管理多语言规则
### 清理死代码
- 删除 `UserPreferences`/`RuntimePromptContext` 及全部辅助函数
- 删除 runner 中 `runtime_client_time` 参数链路
- 删除 `SystemAgentRuntimeConfig.extra_context`
- 删除 `sections.py``env` section marker
- 删除 `AgentPromptRegistry` 死代码
- runner 中 `ai_language``user_context.settings.preferences` 提取传入prompt
### 安全规则
- `AGENTS.md` 添加 Git Safety 规则(禁止未经批准的破坏性git操作)
- `.opencode/opencode.json` 添加高危git命令审批配置
### 测试
- 新增 22 个六爻算法单元测试
- 重写 7 个 prompt 测试适配新签名
- 全部 85 个单元测试通过
**修改文件 (14)**:
- `backend/src/core/divination/derivation.py`
- `backend/src/schemas/domain/divination.py`
- `apps/lib/features/divination/data/models/divination_backend_models.dart`
- `backend/src/core/agentscope/prompts/system_prompt.py`
- `backend/src/core/agentscope/prompts/agent_prompt.py`
- `backend/src/core/agentscope/prompts/worker_rules.py` (新)
- `backend/src/core/agentscope/prompts/sections.py`
- `backend/src/core/agentscope/prompts/user_prompt.py`
- `backend/src/core/agentscope/runtime/runner.py`
- `backend/tests/unit/test_agentscope_prompts.py`
- `backend/tests/unit/test_divination_derivation.py` (新)
- `docs/plans/liuyao-algorithm-audit.md`
- `AGENTS.md`
- `.opencode/opencode.json`
### Git Commits
| Hash | Message |
|------|---------|
| `9598d16` | (see git log) |
| `be68681` | (see git log) |
### Testing
- [OK] (Add test results)
### Status
[OK] **Completed**
### Next Steps
- None - task complete
## Session 8: Session deletion anonymization for iOS compliance
**Date**: 2026-04-15
**Task**: Session deletion anonymization for iOS compliance
### Summary
Replace soft-delete with anonymize + hard-delete. Add anonymous_session_snapshots table for analytics. Remove points_ledger.biz_id FK constraint for snapshot-style reference.
### Main Changes
### Git Commits
| Hash | Message |
|------|---------|
| `c2b726e` | (see git log) |
### Testing
- [OK] (Add test results)
### Status
[OK] **Completed**
### Next Steps
- None - task complete
## Session 9: 起卦教程首次访问追踪 + Agent时间上下文
## Session 1: 新人初始礼包购买追踪功能 - 计划制定
**Date**: 2026-04-16
**Task**: 起卦教程首次访问追踪 + Agent时间上下文
**Task**: starter-package-purchase-tracking
### Summary
实现了起卦教程首次访问追踪功能,包括后端 ProfileSettings 添加 DivinationTutorialSettings 字段、前端三处起卦页面添加首次访问检测和弹窗提示、使用本地状态管理避免并发覆盖问题、Agent系统提示添加时间上下文信息
在独立 worktree 中创建新人初始礼包购买追踪功能的实现计划
### Main Changes
### Background
用户需求:追踪用户是否已购买新人初始礼包($0.99/60积分),以便前端支付页面决定是否展示该礼包。
### Analysis
### Git Commits
1. **现有数据结构分析**
- `register_bonus_claims` 表已存在,用于注册送分去重
- 表结构包含:`email_hash`(唯一)、`user_email_snapshot``first_user_id_snapshot``balance_snapshot``grant_event_id`
- 已支持删除账号后重注册恢复余额的功能
| Hash | Message |
2. **设计方案**
-`register_bonus_claims` 添加 `has_purchased_starter_pack` 字段
- 利用现有 `email_hash` 唯一约束保证同一邮箱只能购买一次
- 后端提供 `/api/v1/points/starter-pack/eligibility` API
- 前端根据 API 返回决定是否展示礼包
3. **参考现有实现**
- 已有计划文档:`docs/plans/ios-new-user-pack-payment-plan.md`
- 完整支付计划包含:支付订单表、支付事件审计表、验单流程等
- 本期仅实现资格查询,不涉及支付流程
### Implementation Plan
#### Phase 1: 数据库层
- 创建 Alembic 迁移:`20260416_0001_add_starter_pack_tracking.py`
- 添加字段:`has_purchased_starter_pack BOOLEAN NOT NULL DEFAULT FALSE`
- 更新 Model`backend/src/models/register_bonus_claims.py`
#### Phase 2: 后端 API
- Schema:定义 `StarterPackEligibilityResponse``StarterPackInfo`
- Repository:添加 `has_purchased_starter_pack()` 方法
- Service:实现 `check_starter_pack_eligibility()` 逻辑
- Router:添加 `GET /api/v1/points/starter-pack/eligibility` 路由
#### Phase 3: 前端集成
- 创建 API 调用层
- 更新支付页面 UI 展示逻辑
#### Phase 4: 文档与测试
- 更新协议文档
- 编写单元测试和集成测试
### Key Decisions
1. **字段命名**`has_purchased_starter_pack`(布尔型),简洁明确
2. **API 设计**:仅返回资格状态和礼包信息,不涉及支付逻辑
3. **本期限制**
- 不实现支付流程
- 不实现支付验证
- `has_purchased_starter_pack` 字段暂时只读
### Files Created
| File | Purpose |
|------|---------|
| `69b34bd` | (see git log) |
### Testing
- [OK] (Add test results)
### Status
[OK] **Completed**
| `.trellis/tasks/04-16-starter-package-purchase-tracking/task.json` | 任务配置 |
| `.trellis/tasks/04-16-starter-package-purchase-tracking/prd.md` | 需求与实现计划 |
### Next Steps
- None - task complete
1. 实现 Phase 1:数据库迁移和 Model 更新
2. 实现 Phase 2:后端 API
3. 实现 Phase 3:前端集成
4. 实现 Phase 4:文档与测试
### Status
# **In Progress** - 计划已制定,等待实现
@@ -0,0 +1,14 @@
import 'package:dio/dio.dart';
import '../models/package_info.dart';
class PointsApi {
const PointsApi(this._dio);
final Dio _dio;
Future<PackagesResult> getPackages() async {
final response = await _dio.get('/api/v1/points/packages');
return PackagesResult.fromJson(response.data as Map<String, dynamic>);
}
}
@@ -0,0 +1,71 @@
enum ProductCode { newUserPack, basicPack, popularPack, premiumPack }
enum PackageType { starter, regular }
class PackageInfo {
const PackageInfo({
required this.productCode,
required this.type,
required this.price,
required this.credits,
required this.isStarter,
required this.starterEligible,
required this.sortOrder,
});
final ProductCode productCode;
final PackageType type;
final double price;
final int credits;
final bool isStarter;
final bool starterEligible;
final int sortOrder;
factory PackageInfo.fromJson(Map<String, dynamic> json) {
return PackageInfo(
productCode: _parseProductCode(json['productCode'] as String),
type: json['type'] == 'starter'
? PackageType.starter
: PackageType.regular,
price: (json['price'] as num).toDouble(),
credits: json['credits'] as int,
isStarter: json['isStarter'] as bool,
starterEligible: json['starterEligible'] as bool,
sortOrder: json['sortOrder'] as int,
);
}
static ProductCode _parseProductCode(String code) {
return switch (code) {
'new_user_pack' => ProductCode.newUserPack,
'basic_pack' => ProductCode.basicPack,
'popular_pack' => ProductCode.popularPack,
'premium_pack' => ProductCode.premiumPack,
_ => throw ArgumentError('Unknown product code: $code'),
};
}
String get priceDisplay => '\$${price.toStringAsFixed(2)}';
}
class PackagesResult {
const PackagesResult({
required this.region,
required this.currency,
required this.packages,
});
final String region;
final String currency;
final List<PackageInfo> packages;
factory PackagesResult.fromJson(Map<String, dynamic> json) {
return PackagesResult(
region: json['region'] as String,
currency: json['currency'] as String,
packages: (json['packages'] as List<dynamic>)
.map((e) => PackageInfo.fromJson(e as Map<String, dynamic>))
.toList(),
);
}
}
@@ -111,7 +111,7 @@ class ProfileApi {
aiLanguage: (preferencesRaw['ai_language'] as String?) ?? 'zh-CN',
timezone:
(preferencesRaw['timezone'] as String?) ?? 'Asia/Shanghai',
country: (preferencesRaw['country'] as String?) ?? 'CN',
country: (preferencesRaw['country'] as String?) ?? 'US',
)
: const PreferenceSettings();
@@ -16,7 +16,7 @@ class PreferenceSettings {
this.interfaceLanguage = 'zh-CN',
this.aiLanguage = 'zh-CN',
this.timezone = 'Asia/Shanghai',
this.country = 'CN',
this.country = 'US',
});
final String interfaceLanguage;
@@ -1,15 +1,66 @@
import 'package:flutter/material.dart';
import '../../../../app/di/injection.dart';
import '../../../../core/auth/session_store.dart';
import '../../../../core/logging/logger.dart';
import '../../../../data/network/api_client.dart';
import '../../../../data/storage/local_kv_store.dart';
import '../../../../l10n/app_localizations.dart';
import '../../../../shared/theme/app_color_palette.dart';
import '../../../../shared/theme/design_tokens.dart';
import '../../../points/data/apis/points_api.dart';
import '../../../points/data/models/package_info.dart';
import '../widgets/settings_section_widgets.dart';
class CoinCenterScreen extends StatelessWidget {
class CoinCenterScreen extends StatefulWidget {
const CoinCenterScreen({super.key, required this.balance});
final int balance;
@override
State<CoinCenterScreen> createState() => _CoinCenterScreenState();
}
class _CoinCenterScreenState extends State<CoinCenterScreen> {
final Logger _logger = getLogger('features.settings.coin_center_screen');
List<PackageInfo>? _packages;
bool _isLoading = true;
@override
void initState() {
super.initState();
_loadPackages();
}
Future<void> _loadPackages() async {
try {
final sessionStore = SessionStore(LocalKvStore());
final apiClient = ApiClient(
baseUrl: appDependencies.backendUrl,
tokenProvider: sessionStore.getToken,
);
final api = PointsApi(apiClient.rawDio);
final result = await api.getPackages();
if (mounted) {
setState(() {
_packages = result.packages;
_isLoading = false;
});
}
} catch (e, stackTrace) {
_logger.error(
message: 'Failed to load packages',
error: e,
stackTrace: stackTrace,
);
if (mounted) {
setState(() {
_isLoading = false;
});
}
}
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
@@ -52,7 +103,7 @@ class CoinCenterScreen extends StatelessWidget {
),
const SizedBox(height: AppSpacing.xs),
Text(
l10n.settingsCoinBalanceValue(balance),
l10n.settingsCoinBalanceValue(widget.balance),
style: Theme.of(
context,
).textTheme.headlineMedium?.copyWith(color: colors.onPrimary),
@@ -69,26 +120,55 @@ class CoinCenterScreen extends StatelessWidget {
),
const SizedBox(height: AppSpacing.xl),
SectionLabel(text: l10n.settingsCoinRechargeSection),
CoinPackageCard(
title: l10n.settingsCoinPackBasic,
price: '\$4.99',
amount: 100,
),
const SizedBox(height: AppSpacing.md),
CoinPackageCard(
title: l10n.settingsCoinPackPopular,
price: '\$7.99',
amount: 210,
badge: l10n.settingsCoinPackPopularBadge,
),
const SizedBox(height: AppSpacing.md),
CoinPackageCard(
title: l10n.settingsCoinPackPremium,
price: '\$12.99',
amount: 415,
),
..._buildPackageCards(l10n),
],
),
);
}
List<Widget> _buildPackageCards(AppLocalizations l10n) {
if (_isLoading) {
return [
const Padding(
padding: EdgeInsets.all(AppSpacing.xl),
child: Center(child: CircularProgressIndicator()),
),
];
}
if (_packages == null || _packages!.isEmpty) {
return [];
}
return List.generate(_packages!.length, (index) {
final pkg = _packages![index];
return Column(
children: [
if (index > 0) const SizedBox(height: AppSpacing.md),
CoinPackageCard(
title: _getPackageTitle(pkg, l10n),
price: pkg.priceDisplay,
amount: pkg.credits,
badge: _getPackageBadge(pkg, l10n),
),
],
);
});
}
String? _getPackageBadge(PackageInfo pkg, AppLocalizations l10n) {
if (pkg.productCode == ProductCode.popularPack) {
return l10n.settingsCoinPackPopularBadge;
}
return null;
}
String _getPackageTitle(PackageInfo pkg, AppLocalizations l10n) {
return switch (pkg.productCode) {
ProductCode.newUserPack => l10n.settingsCoinPackStarter,
ProductCode.basicPack => l10n.settingsCoinPackBasic,
ProductCode.popularPack => l10n.settingsCoinPackPopular,
ProductCode.premiumPack => l10n.settingsCoinPackPremium,
};
}
}
+1
View File
@@ -190,6 +190,7 @@
},
"settingsCoinCenterDescription": "",
"settingsCoinRechargeSection": "Recharge Packages",
"settingsCoinPackStarter": "New User Pack",
"settingsCoinPackBasic": "Starter Pack",
"settingsCoinPackPopular": "Popular Pack",
"settingsCoinPackPremium": "Premium Pack",
+6
View File
@@ -939,6 +939,12 @@ abstract class AppLocalizations {
/// **'充值套餐'**
String get settingsCoinRechargeSection;
/// No description provided for @settingsCoinPackStarter.
///
/// In zh, this message translates to:
/// **'新人专享包'**
String get settingsCoinPackStarter;
/// No description provided for @settingsCoinPackBasic.
///
/// In zh, this message translates to:
+3
View File
@@ -469,6 +469,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get settingsCoinRechargeSection => 'Recharge Packages';
@override
String get settingsCoinPackStarter => 'New User Pack';
@override
String get settingsCoinPackBasic => 'Starter Pack';
+6
View File
@@ -454,6 +454,9 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get settingsCoinRechargeSection => '充值套餐';
@override
String get settingsCoinPackStarter => '新人专享包';
@override
String get settingsCoinPackBasic => '入门补充包';
@@ -1460,6 +1463,9 @@ class AppLocalizationsZhHant extends AppLocalizationsZh {
@override
String get settingsCoinRechargeSection => '儲值套餐';
@override
String get settingsCoinPackStarter => '新人專享包';
@override
String get settingsCoinPackBasic => '入門補充包';
+1
View File
@@ -190,6 +190,7 @@
},
"settingsCoinCenterDescription": "",
"settingsCoinRechargeSection": "充值套餐",
"settingsCoinPackStarter": "新人专享包",
"settingsCoinPackBasic": "入门补充包",
"settingsCoinPackPopular": "常用加量包",
"settingsCoinPackPremium": "高频进阶包",
+1
View File
@@ -123,6 +123,7 @@
},
"settingsCoinCenterDescription": "",
"settingsCoinRechargeSection": "儲值套餐",
"settingsCoinPackStarter": "新人專享包",
"settingsCoinPackBasic": "入門補充包",
"settingsCoinPackPopular": "常用加量包",
"settingsCoinPackPremium": "高頻進階包",
@@ -0,0 +1,32 @@
"""add has_purchased_starter_pack to register_bonus_claims
Revision ID: 20260416_0001
Revises: 20260413_0004
Create Date: 2026-04-16 12:00:00
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "20260416_0001"
down_revision: Union[str, Sequence[str], None] = "20260415_0002"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"register_bonus_claims",
sa.Column(
"has_purchased_starter_pack",
sa.Boolean(),
nullable=False,
server_default=sa.text("false"),
),
)
def downgrade() -> None:
op.drop_column("register_bonus_claims", "has_purchased_starter_pack")
+3 -9
View File
@@ -15,6 +15,7 @@ from core.logging import get_logger
from models.llm import Llm
from models.llm_factory import LlmFactory
from models.system_agents import SystemAgents
from utils.paths import get_llm_catalog_config_path, get_system_agents_config_path
logger = get_logger("core.config.initial.init_data")
@@ -48,9 +49,7 @@ class SystemAgentsYaml(BaseModel):
def _default_catalog_path() -> Path:
return (
Path(__file__).resolve().parents[1] / "static" / "database" / "llm_catalog.yaml"
)
return get_llm_catalog_config_path()
def load_llm_catalog(catalog_path: Path | None = None) -> dict[str, Any]:
@@ -77,12 +76,7 @@ def load_llm_catalog(catalog_path: Path | None = None) -> dict[str, Any]:
def _default_system_agents_path() -> Path:
return (
Path(__file__).resolve().parents[1]
/ "static"
/ "database"
/ "system_agents.yaml"
)
return get_system_agents_config_path()
def load_system_agents(catalog_path: Path | None = None) -> dict[str, Any]:
@@ -24,6 +24,7 @@ from core.config.notification.static_schema import (
from models.auth_user import AuthUser
from models.notification import Notification
from models.user_notification import UserNotification
from utils.paths import get_notification_config_dir
logger = get_logger("core.config.notification.static_sync")
@@ -41,12 +42,7 @@ class StaticNotificationSyncResult:
def default_static_notification_path() -> Path:
return (
Path(__file__).resolve().parents[1]
/ "static"
/ "notification"
/ "notifications"
)
return get_notification_config_dir()
def load_static_notification_documents(
@@ -0,0 +1,21 @@
from core.config.packages.registry import (
clear_packages_cache,
get_packages_config_for_region,
)
from core.config.packages.schema import (
PackageConfig,
PackageType,
ProductCode,
RegionPackagesConfig,
load_packages_config,
)
__all__ = [
"clear_packages_cache",
"get_packages_config_for_region",
"load_packages_config",
"PackageConfig",
"PackageType",
"ProductCode",
"RegionPackagesConfig",
]
@@ -0,0 +1,34 @@
from __future__ import annotations
from core.config.packages.schema import (
RegionPackagesConfig,
load_packages_config,
)
from utils.paths import get_default_package_config_path, get_package_config_path
_CONFIG_CACHE: dict[str, RegionPackagesConfig] = {}
def get_packages_config_for_region(country: str) -> RegionPackagesConfig:
if country in _CONFIG_CACHE:
return _CONFIG_CACHE[country]
region_file = get_package_config_path(country)
if region_file.exists():
config = load_packages_config(region_file)
_CONFIG_CACHE[country] = config
return config
default_file = get_default_package_config_path()
if not default_file.exists():
raise RuntimeError(f"No default packages config found: {default_file}")
config = load_packages_config(default_file)
_CONFIG_CACHE[country] = config
return config
def clear_packages_cache() -> None:
global _CONFIG_CACHE
_CONFIG_CACHE = {}
@@ -0,0 +1,51 @@
from __future__ import annotations
from enum import Enum
from pathlib import Path
from typing import ClassVar, Literal
import yaml
from pydantic import BaseModel, ConfigDict, Field, ValidationError
class PackageType(str, Enum):
STARTER = "starter"
REGULAR = "regular"
ProductCode = Literal[
"new_user_pack",
"basic_pack",
"popular_pack",
"premium_pack",
]
class PackageConfig(BaseModel):
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
product_code: ProductCode
type: PackageType
price: float = Field(ge=0)
credits: int = Field(ge=1)
sort_order: int = Field(default=0, ge=0)
enabled: bool = Field(default=True)
class RegionPackagesConfig(BaseModel):
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
region: str = Field(min_length=1, max_length=8)
currency: str = Field(min_length=1, max_length=8)
packages: list[PackageConfig] = Field(min_length=1)
def load_packages_config(path: Path) -> RegionPackagesConfig:
with path.open("r", encoding="utf-8") as file:
loaded: object = yaml.safe_load(file) or {}
if not isinstance(loaded, dict):
raise ValueError(f"Invalid packages config format: {path}")
try:
return RegionPackagesConfig.model_validate(loaded)
except ValidationError as exc:
raise ValueError(f"Invalid packages config data: {path}") from exc
@@ -0,0 +1,30 @@
region: DEFAULT
currency: USD
packages:
- product_code: new_user_pack
type: starter
price: 0.99
credits: 60
sort_order: 0
enabled: true
- product_code: basic_pack
type: regular
price: 4.99
credits: 100
sort_order: 10
enabled: true
- product_code: popular_pack
type: regular
price: 7.99
credits: 210
sort_order: 20
enabled: true
- product_code: premium_pack
type: regular
price: 12.99
credits: 415
sort_order: 30
enabled: true
@@ -0,0 +1,30 @@
region: US
currency: USD
packages:
- product_code: new_user_pack
type: starter
price: 0.99
credits: 60
sort_order: 0
enabled: true
- product_code: basic_pack
type: regular
price: 4.99
credits: 100
sort_order: 10
enabled: true
- product_code: popular_pack
type: regular
price: 7.99
credits: 210
sort_order: 20
enabled: true
- product_code: premium_pack
type: regular
price: 12.99
credits: 415
sort_order: 30
enabled: true
@@ -5,6 +5,8 @@ from functools import lru_cache
import json
from pathlib import Path
from utils.paths import get_gua_catalog_path
@dataclass(frozen=True)
class GuaCatalogItem:
@@ -24,8 +26,7 @@ class GuaCatalogItem:
def _resolve_catalog_file() -> Path:
current = Path(__file__).resolve()
target = current.parent / "data/gua_catalog.json"
target = get_gua_catalog_path()
if not target.exists():
raise FileNotFoundError(f"gua_catalog.json not found: {target}")
return target
+4 -1
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
import uuid
from sqlalchemy import BigInteger, String, Text, UniqueConstraint
from sqlalchemy import BigInteger, Boolean, String, Text, UniqueConstraint
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
@@ -30,3 +30,6 @@ class RegisterBonusClaims(TimestampMixin, Base):
)
balance_snapshot: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
grant_event_id: Mapped[str] = mapped_column(String(64), nullable=False)
has_purchased_starter_pack: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False
)
+1 -1
View File
@@ -14,7 +14,7 @@ class PreferenceSettings(BaseModel):
interface_language: str = "zh-CN"
ai_language: str = "zh-CN"
timezone: str = "Asia/Shanghai"
country: str = "CN"
country: str = "US"
@field_validator("interface_language", "ai_language")
@classmethod
+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,
RuntimeConfig,
)
from utils.paths import get_system_agents_config_path
def _default_system_agents_path() -> Path:
return (
Path(__file__).resolve().parents[2]
/ "core"
/ "config"
/ "static"
/ "database"
/ "system_agents.yaml"
)
return get_system_agents_config_path()
def _load_system_agents_yaml(path: Path | None = None) -> dict[str, object]:
+20
View File
@@ -10,6 +10,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from models.agent_chat_message import AgentChatMessage
from models.points_audit_ledger import PointsAuditLedger
from models.points_ledger import PointsLedger
from models.profile import Profile
from models.register_bonus_claims import RegisterBonusClaims
from models.user_points import UserPoints
from schemas.domain.points import (
@@ -189,3 +190,22 @@ class PointsRepository:
claim.balance_snapshot = int(balance_snapshot)
await self._session.flush()
return True
async def has_purchased_starter_pack(
self,
*,
email_hash: str,
) -> bool:
claim = await self.get_register_bonus_claim(email_hash=email_hash)
if claim is None:
return False
return bool(claim.has_purchased_starter_pack)
async def get_profile_settings(
self,
*,
user_id: UUID,
) -> dict[str, object] | None:
stmt = select(Profile.settings).where(Profile.id == user_id).limit(1)
row = (await self._session.execute(stmt)).scalar_one_or_none()
return row
+29 -1
View File
@@ -6,7 +6,7 @@ from fastapi import APIRouter, Depends
from core.auth.models import CurrentUser
from v1.points.dependencies import get_points_service
from v1.points.schemas import PointsBalanceResponse
from v1.points.schemas import PackagesResponse, PackageInfo, PointsBalanceResponse
from v1.points.service import PointsService
from v1.users.dependencies import get_current_user
@@ -26,3 +26,31 @@ async def get_points_balance(
runCost=result.run_cost,
canRun=result.can_run,
)
@router.get("/packages", response_model=PackagesResponse)
async def get_available_packages(
service: Annotated[PointsService, Depends(get_points_service)],
current_user: Annotated[CurrentUser, Depends(get_current_user)],
) -> PackagesResponse:
result = await service.get_available_packages(
user_id=current_user.id,
user_email=current_user.email or "",
)
return PackagesResponse(
region=result.region,
currency=result.currency,
packages=[
PackageInfo(
productCode=pkg.product_code,
type=pkg.type.value,
price=pkg.price,
credits=pkg.credits,
isStarter=pkg.is_starter,
starterEligible=pkg.starter_eligible,
sortOrder=pkg.sort_order,
)
for pkg in result.packages
],
)
+22
View File
@@ -1,5 +1,7 @@
from __future__ import annotations
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field
@@ -11,3 +13,23 @@ class PointsBalanceResponse(BaseModel):
available_balance: int = Field(alias="availableBalance", ge=0)
run_cost: int = Field(alias="runCost", gt=0)
can_run: bool = Field(alias="canRun")
class PackageInfo(BaseModel):
model_config = ConfigDict(populate_by_name=True, serialize_by_alias=True)
product_code: str = Field(alias="productCode", min_length=1, max_length=128)
type: Literal["starter", "regular"]
price: float = Field(ge=0)
credits: int = Field(ge=1)
is_starter: bool = Field(alias="isStarter")
starter_eligible: bool = Field(alias="starterEligible")
sort_order: int = Field(alias="sortOrder", ge=0)
class PackagesResponse(BaseModel):
model_config = ConfigDict(populate_by_name=True, serialize_by_alias=True)
region: str = Field(min_length=1, max_length=8)
currency: str = Field(min_length=1, max_length=8)
packages: list[PackageInfo]
+74 -1
View File
@@ -4,9 +4,13 @@ from dataclasses import dataclass
from decimal import Decimal
import hashlib
import hmac
from typing import Literal
from typing import TYPE_CHECKING, Literal
from uuid import UUID, uuid4
from core.config.packages import (
PackageType,
get_packages_config_for_region,
)
from core.config.settings import config
from core.http.errors import ApiProblemError, problem_payload
from schemas.domain.points import (
@@ -18,8 +22,12 @@ from schemas.domain.points import (
)
from schemas.enums import PointsBizType, PointsChangeType, PointsOperatorType
from schemas.domain.points import ApplyPointsChangeCommand
from schemas.shared.user import parse_profile_settings
from v1.points.repository import PointsRepository
if TYPE_CHECKING:
pass
RUN_POINTS_COST = 20
@@ -55,6 +63,24 @@ class RegisterBonusResult:
event_id: str
@dataclass(frozen=True)
class PackageInfoResult:
product_code: str
type: PackageType
price: float
credits: int
sort_order: int
is_starter: bool
starter_eligible: bool
@dataclass(frozen=True)
class PackagesResult:
region: str
currency: str
packages: list[PackageInfoResult]
class PointsService:
def __init__(self, repository: PointsRepository) -> None:
self._repository = repository
@@ -408,3 +434,50 @@ class PointsService:
digestmod=hashlib.sha256,
)
return digest.hexdigest()
async def get_available_packages(
self,
*,
user_id: UUID,
user_email: str,
) -> PackagesResult:
settings_raw = await self._repository.get_profile_settings(user_id=user_id)
settings = parse_profile_settings(settings_raw)
country = settings.preferences.country
pkg_config = get_packages_config_for_region(country)
normalized_email = self._normalize_email(user_email)
has_starter = False
if normalized_email:
email_hash = self._build_register_bonus_email_hash(normalized_email)
has_starter = await self._repository.has_purchased_starter_pack(
email_hash=email_hash
)
packages: list[PackageInfoResult] = []
for pkg in pkg_config.packages:
if not pkg.enabled:
continue
if pkg.type == PackageType.STARTER and has_starter:
continue
packages.append(
PackageInfoResult(
product_code=pkg.product_code,
type=pkg.type,
price=pkg.price,
credits=pkg.credits,
sort_order=pkg.sort_order,
is_starter=pkg.type == PackageType.STARTER,
starter_eligible=(
pkg.type == PackageType.STARTER and not has_starter
),
)
)
return PackagesResult(
region=pkg_config.region,
currency=pkg_config.currency,
packages=sorted(packages, key=lambda p: p.sort_order),
)
@@ -1,247 +0,0 @@
# iOS 新人包支付接入与一次性权益计划
## 1. 背景与目标
当前前端充值页为静态套餐展示,购买按钮未接入真实支付链路。现需新增 iOS 新人包:
- 价格:`$0.99`
- 积分:`60`
- 资格:同邮箱只能购买一次
- 删除账号后同邮箱重新注册,不刷新新人包资格
同时补齐后端真实支付路由与订单审计能力,前端不再硬编码套餐。
## 2. 本次范围
### 2.1 In Scope
1. 后端新增 iOS 支付相关路由(下单/验单/查询/回调)。
2. 新建支付订单主表与支付事件审计表。
3. 改造 `register_bonus_claims` 为可承载“权益唯一占用”能力。
4. 前端套餐由后端接口驱动,不再硬编码三档固定套餐。
5. 新人包资格前后端联动(展示、购买、验单、入账)。
### 2.2 Out of Scope
1. Android 支付渠道接入。
2. Apple 开发者账号正式联调(当前账号未就绪)。
3. 财务对账后台页面。
## 3. 数据模型设计
## 3.1 新建表:`payment_orders`
用途:订单当前态,支持幂等验单与退款状态跟踪。
建议字段:
- `id` UUID PK
- `order_no` VARCHAR(64) UNIQUE
- `user_id` UUID NOT NULL (`auth.users.id`)
- `channel` VARCHAR(16) NOT NULL (`ios_iap`)
- `product_code` VARCHAR(64) NOT NULL(例:`new_user_pack_099_60`
- `price_usd` NUMERIC(12,6) NOT NULL
- `credits` BIGINT NOT NULL
- `currency` VARCHAR(8) NOT NULL DEFAULT `USD`
- `status` VARCHAR(24) NOT NULL
- `created|receipt_submitted|verified|credited|refund_pending|refunded|revoked|failed`
- `apple_transaction_id` VARCHAR(128) NULL UNIQUE
- `apple_original_transaction_id` VARCHAR(128) NULL
- `app_account_token` UUID NULL
- `idempotency_key` VARCHAR(128) NULL UNIQUE
- `error_code` VARCHAR(64) NULL
- `error_message` TEXT NULL
- `created_at` / `updated_at`
关键约束:
- `credits > 0`
- `price_usd >= 0`
- `status` check
- `channel='ios_iap'`(本期)
## 3.2 新建表:`payment_order_events`
用途:支付事件不可变审计流水(验单结果、回调、退款、冲正)。
建议字段:
- `id` UUID PK
- `order_id` UUID NOT NULL FK `payment_orders.id`
- `event_type` VARCHAR(32) NOT NULL
- `order_created|receipt_submitted|verify_success|verify_failed|credited|refund_notified|refunded|revoke_notified|reversed`
- `event_source` VARCHAR(24) NOT NULL
- `api|apple_server_notification|job`
- `event_idempotency_key` VARCHAR(128) NULL UNIQUE
- `payload` JSONB NOT NULL
- `operator_id` UUID NULL
- `created_at`
## 3.3 改造表:`register_bonus_claims`
目标:从“注册送分去重”升级为“权益唯一占用”。
新增字段建议:
- `offer_code` VARCHAR(64) NOT NULL(例:`register_bonus_20``new_user_pack_099_60`
- `claim_source` VARCHAR(24) NOT NULL`register_bonus|ios_purchase`
- `claim_order_id` UUID NULL FK `payment_orders.id`
新增唯一约束:
- `UNIQUE(offer_code, email_hash)`
保留行为:
- `first_user_id` 允许 `ON DELETE SET NULL`,保证删号后资格仍占用。
## 4. 路由与服务边界
## 4.1 后端新增路由(v1
1. `GET /api/v1/payments/packages`
- 返回可购买套餐列表与用户资格(是否可买新人包)。
2. `POST /api/v1/payments/orders`
- 创建订单,返回 `orderNo` 与客户端支付所需参数。
3. `POST /api/v1/payments/orders/{orderNo}/verify-ios-receipt`
- 提交 iOS 收据,后端调用 Apple 校验。
4. `GET /api/v1/payments/orders/{orderNo}`
- 查询订单状态与入账结果。
5. `POST /api/v1/payments/webhooks/apple`
- 接收 App Store Server Notifications V2,处理退款/撤销。
## 4.2 分层职责
- Router:鉴权、请求校验、RFC7807 错误映射。
- Service
- 资格判断(新人包是否可买)
- 下单与验单业务编排
- 入账积分与冲正
- 幂等控制
- Repository
- `payment_orders`/`payment_order_events`/`register_bonus_claims` 读写
- 订单状态流转条件更新
## 5. 核心流程
## 5.1 下单与资格检查
```text
客户端请求套餐 -> GET /payments/packages
-> 后端按 email_hash 检查 offer_code='new_user_pack_099_60' 是否已占用
-> 返回 eligible=true/false
客户端创建订单 -> POST /payments/orders
-> 再次做资格校验(防并发)
-> 创建 payment_orders(status=created)
-> 写 payment_order_events(order_created)
```
## 5.2 iOS 验单与积分入账
```text
客户端支付后提交 receipt -> POST /orders/{orderNo}/verify-ios-receipt
-> 后端调用 Apple 验单(可切 sandbox
-> 验证 transaction_id 幂等
-> 状态 verified
-> 原子事务:
1) 占用权益 register_bonus_claims(offer_code,email_hash)
2) 写 points_ledger(grant)
3) 写 points_audit_ledger(direction=1,billed_to='user')
4) 订单置 credited
5) 写 payment_order_events(credited)
```
## 5.3 退款与冲正
```text
Apple 回调退款 -> POST /payments/webhooks/apple
-> 定位 order(transaction_id / original_transaction_id)
-> 幂等处理通知
-> 状态 refunded/revoked
-> 原子事务:
1) 写 points_ledger(adjust/consume reverse)
2) 写 points_audit_ledger(direction=-1,billed_to='platform',metadata.reason='refund')
3) 写 payment_order_events(refunded/reversed)
```
## 6. 信任边界与风控
1. 客户端价格、积分、product_code 全部不可信,按后端配置为准。
2. 不信任客户端“支付成功”标记,必须后端验单通过才入账。
3. Apple 回调需验签(JWS)并做 `notificationUUID` 幂等。
4. 订单与入账使用数据库事务,失败不允许半成功。
5. `offer_code + email_hash` 唯一约束是最终防线。
## 7. 前端改造
当前 `CoinCenterScreen` 中套餐硬编码,需改为 API 驱动:
- 页面加载调用 `GET /api/v1/payments/packages`
- 渲染返回的套餐列表
- 新人包 `eligible=false` 时展示“已购买/不可购买”态
- 点击购买后走真实支付流(创建订单 -> 拉起 IAP -> 提交 receipt
## 8. 无 Apple 账号阶段的交付策略
在无开发者账号前,先做可替换的验单适配层:
- `IOSReceiptVerifier` 接口(生产实现 + mock 实现)
- 通过配置开关使用 mock 结果跑通后端链路与前端状态
- 后续只替换 verifier 实现,不改订单主流程
## 9. 测试计划
## 9.1 后端单元测试
1. 新人包资格判定(首次可买、重复不可买、删号重注册不可买)
2. 验单幂等(同 transaction_id 不重复入账)
3. 退款冲正幂等(同通知不重复冲正)
## 9.2 后端集成测试
1. 首次注册 -> 下单 -> 验单 -> 入账 60
2. 删除账号 -> 同邮箱重注册 -> 新人包不可买
3. 退款通知 -> 积分冲正 -> 订单状态更新
## 9.3 前端集成测试
1. 套餐接口渲染(替代硬编码)
2. 新人包可买/不可买状态切换
3. 支付中/成功/失败/退款状态展示
## 10. 里程碑拆分
### PR1(数据层)
- 迁移:新建 `payment_orders``payment_order_events`
- 迁移:改造 `register_bonus_claims`
- 模型与 repository
### PR2(后端业务)
- 支付路由 + service
- iOS 验单适配层(先 mock
- 订单与积分入账/冲正
### PR3(前端)
- 套餐改 API 驱动
- 新人包购买态与禁用态
- 下单/验单交互链路
### PR4(联调与验证)
- 使用集成测试回归全流程
- Apple 账号就绪后切换真实 verifier
## 11. 变更类型判定
这是 **新 Feature**,不是现有功能的小修补。
理由:
1. 引入了新的支付域模型和事件审计。
2. 引入了新的后端支付路由与验单流程。
3. 前端从静态展示升级为可交易流程。
4. 增加了退款冲正与 iOS 回调处理能力。
-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
- PK: `id`
- Core fields: `email_hash`, `user_email_snapshot`, `first_user_id_snapshot`, `balance_snapshot`, `grant_event_id`, `created_at`, `updated_at`
- Core fields: `email_hash`, `user_email_snapshot`, `first_user_id_snapshot`, `balance_snapshot`, `grant_event_id`, `has_purchased_starter_pack`, `created_at`, `updated_at`
- Constraints:
- `email_hash` unique
- `grant_event_id` unique
@@ -106,6 +106,7 @@ Protocol verification status:
- `email_hash` must be HMAC-SHA256 over normalized email (`trim + lower`)
- key source: backend config `points_policy.register_bonus_hmac_key`
- `balance_snapshot` stores the latest pre-delete account balance for same-email re-registration recovery
- `has_purchased_starter_pack` tracks whether user has purchased the starter pack ($0.99/60 credits)
#### points_ledger.metadata (schema_version=1)
@@ -206,3 +207,93 @@ Managed by `python -m core.runtime.cli sync-notifications [flags]`:
- `--source-key <key>` — sync only the notification with the matching `source_key`
Run after migrations on fresh environments or after adding new notification YAML definitions. Not included in `bootstrap` to keep bootstrap fast and free of unintended side effects.
## Packages API
### GET /api/v1/points/packages
Returns available purchase packages for the current user's region, including starter pack eligibility.
**Request:**
- Auth: Required (JWT)
- Headers: `Authorization: Bearer <token>`
**Response:**
```json
{
"region": "US",
"currency": "USD",
"packages": [
{
"productCode": "new_user_pack_099_60",
"type": "starter",
"priceUsd": "0.99",
"credits": 60,
"badge": null,
"isStarter": true,
"starterEligible": true,
"sortOrder": 0
},
{
"productCode": "basic_pack_499_100",
"type": "regular",
"priceUsd": "4.99",
"credits": 100,
"badge": null,
"isStarter": false,
"starterEligible": false,
"sortOrder": 10
}
]
}
```
**Fields:**
- `region`: ISO 3166-1 alpha-2 country code (e.g., "US", "CN")
- `currency`: ISO 4217 currency code (e.g., "USD")
- `packages`: List of available packages
- `productCode`: Unique product identifier
- `type`: "starter" (new user pack) or "regular"
- `priceUsd`: Price in USD (decimal string)
- `credits`: Number of credits
- `badge`: Optional badge text (e.g., "Popular")
- `isStarter`: Whether this is a starter pack
- `starterEligible`: Whether user is eligible to purchase starter pack
- `sortOrder`: Display order (ascending)
**Business Logic:**
1. Determine user's region from `profile.settings.preferences.country` (default: "US")
2. Load package configuration from `backend/src/core/config/static/packages/{country}.yaml` (fallback to `default.yaml`)
3. Check starter pack eligibility:
- If `register_bonus_claims.has_purchased_starter_pack = true`, exclude starter pack from response
- Otherwise, include starter pack with `starterEligible: true`
**Configuration Files:**
- Path: `backend/src/core/config/static/packages/`
- Format: YAML
- Example: `us.yaml`
```yaml
region: US
currency: USD
packages:
- product_code: new_user_pack_099_60
type: starter
price_usd: "0.99"
credits: 60
badge: null
sort_order: 0
enabled: true
- product_code: basic_pack_499_100
type: regular
price_usd: "4.99"
credits: 100
badge: null
sort_order: 10
enabled: true
```
**Country/Region Codes:**
- Uses ISO 3166-1 alpha-2 standard
- Default: `US` (United States)
- Examples: `CN` (China), `TW` (Taiwan), `HK` (Hong Kong), `JP` (Japan)