diff --git a/.trellis/tasks/04-16-starter-package-purchase-tracking/prd.md b/.trellis/tasks/04-16-starter-package-purchase-tracking/prd.md new file mode 100644 index 0000000..ee66504 --- /dev/null +++ b/.trellis/tasks/04-16-starter-package-purchase-tracking/prd.md @@ -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 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 diff --git a/.trellis/tasks/04-16-starter-package-purchase-tracking/task.json b/.trellis/tasks/04-16-starter-package-purchase-tracking/task.json new file mode 100644 index 0000000..224dddc --- /dev/null +++ b/.trellis/tasks/04-16-starter-package-purchase-tracking/task.json @@ -0,0 +1,153 @@ +{ + "id": "starter-package-purchase-tracking", + "name": "starter-package-purchase-tracking", + "title": "新人初始礼包购买追踪 + 动态套餐配置", + "description": "在 register_bonus_claims 表添加字段追踪是否购买新人初始礼包,创建静态配置文件管理套餐,提供统一的套餐信息 API", + "status": "planning", + "dev_type": "fullstack", + "scope": ["backend", "apps"], + "priority": "P2", + "creator": "zl-q", + "assignee": "zl-q", + "createdAt": "2026-04-16", + "completedAt": null, + "branch": "worktree/feat-starter-package-purchase-tracking", + "base_branch": "dev", + "worktree_path": "/home/qzl/Code/eryao/.worktrees/feat-starter-package-purchase-tracking", + "current_phase": 0, + "next_action": [ + { + "phase": 1, + "action": "implement" + }, + { + "phase": 2, + "action": "check" + }, + { + "phase": 3, + "action": "finish" + }, + { + "phase": 4, + "action": "create-pr" + } + ], + "commit": null, + "pr_url": null, + "subtasks": [ + { + "id": "static-config", + "title": "静态配置:创建套餐配置文件(us.yaml, default.yaml)", + "status": "completed", + "phase": 1 + }, + { + "id": "config-loader", + "title": "配置加载层:创建 packages loader/schema/registry", + "status": "pending", + "phase": 1 + }, + { + "id": "db-migration", + "title": "数据库迁移:添加 has_purchased_starter_pack 字段", + "status": "pending", + "phase": 1 + }, + { + "id": "backend-model", + "title": "后端 Model 层:更新 RegisterBonusClaims", + "status": "pending", + "phase": 1 + }, + { + "id": "backend-repository", + "title": "后端 Repository 层:添加查询方法", + "status": "pending", + "phase": 1 + }, + { + "id": "backend-service", + "title": "后端 Service 层:实现套餐获取与资格检查逻辑", + "status": "pending", + "phase": 1 + }, + { + "id": "backend-schema", + "title": "后端 Schema 层:定义 PackagesResponse/PackageInfo", + "status": "pending", + "phase": 1 + }, + { + "id": "backend-router", + "title": "后端 Router 层:添加统一 GET /packages 路由", + "status": "pending", + "phase": 1 + }, + { + "id": "profile-default-country", + "title": "Profile 默认值:country 从 CN 改为 US(前后端)", + "status": "pending", + "phase": 1 + }, + { + "id": "backend-tests", + "title": "后端单元测试", + "status": "pending", + "phase": 1 + }, + { + "id": "frontend-models", + "title": "前端数据模型:PackageInfo/PackagesResult", + "status": "pending", + "phase": 1 + }, + { + "id": "frontend-api", + "title": "前端 API 调用层", + "status": "pending", + "phase": 1 + }, + { + "id": "frontend-ui", + "title": "前端 UI:CoinCenterScreen 动态渲染套餐", + "status": "pending", + "phase": 1 + }, + { + "id": "docs-protocol", + "title": "更新协议文档", + "status": "pending", + "phase": 1 + }, + { + "id": "integration-test", + "title": "集成测试验证", + "status": "pending", + "phase": 2 + } + ], + "children": [], + "parent": null, + "relatedFiles": [ + "backend/src/core/config/static/packages/us.yaml", + "backend/src/core/config/static/packages/default.yaml", + "backend/src/core/config/packages/loader.py", + "backend/src/core/config/packages/schema.py", + "backend/src/core/config/packages/registry.py", + "backend/src/models/register_bonus_claims.py", + "backend/src/v1/points/repository.py", + "backend/src/v1/points/service.py", + "backend/src/v1/points/schemas.py", + "backend/src/v1/points/router.py", + "apps/lib/features/settings/data/models/profile_settings.dart", + "apps/lib/features/settings/presentation/screens/coin_center_screen.dart", + "docs/protocols/common/user-points-chat-data-protocol.md" + ], + "notes": "本期不实现支付流程,仅提供套餐信息查询 API。采用 ISO 3166-1 alpha-2 国家代码。", + "meta": { + "country_code_standard": "ISO 3166-1 alpha-2", + "default_country": "US", + "packages_count": 4 + } +} diff --git a/.trellis/workspace/zl-q/index.md b/.trellis/workspace/zl-q/index.md index 5434ffa..72a6c4b 100644 --- a/.trellis/workspace/zl-q/index.md +++ b/.trellis/workspace/zl-q/index.md @@ -8,7 +8,7 @@ - **Active File**: `journal-1.md` -- **Total Sessions**: 9 +- **Total Sessions**: 1 - **Last Active**: 2026-04-16 @@ -19,7 +19,7 @@ | File | Lines | Status | |------|-------|--------| -| `journal-1.md` | ~477 | Active | +| `journal-1.md` | ~80 | Active | --- @@ -29,21 +29,13 @@ | # | 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 | 新人初始礼包购买追踪功能 - 计划制定 | - | --- ## 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 \ No newline at end of file diff --git a/.trellis/workspace/zl-q/journal-1.md b/.trellis/workspace/zl-q/journal-1.md index e0b19ce..67b092c 100644 --- a/.trellis/workspace/zl-q/journal-1.md +++ b/.trellis/workspace/zl-q/journal-1.md @@ -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** - 计划已制定,等待实现 diff --git a/apps/lib/features/points/data/apis/points_api.dart b/apps/lib/features/points/data/apis/points_api.dart new file mode 100644 index 0000000..d53b085 --- /dev/null +++ b/apps/lib/features/points/data/apis/points_api.dart @@ -0,0 +1,14 @@ +import 'package:dio/dio.dart'; + +import '../models/package_info.dart'; + +class PointsApi { + const PointsApi(this._dio); + + final Dio _dio; + + Future getPackages() async { + final response = await _dio.get('/api/v1/points/packages'); + return PackagesResult.fromJson(response.data as Map); + } +} diff --git a/apps/lib/features/points/data/models/package_info.dart b/apps/lib/features/points/data/models/package_info.dart new file mode 100644 index 0000000..ef540d6 --- /dev/null +++ b/apps/lib/features/points/data/models/package_info.dart @@ -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 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 packages; + + factory PackagesResult.fromJson(Map json) { + return PackagesResult( + region: json['region'] as String, + currency: json['currency'] as String, + packages: (json['packages'] as List) + .map((e) => PackageInfo.fromJson(e as Map)) + .toList(), + ); + } +} diff --git a/apps/lib/features/settings/data/apis/profile_api.dart b/apps/lib/features/settings/data/apis/profile_api.dart index df040e9..5bcf12a 100644 --- a/apps/lib/features/settings/data/apis/profile_api.dart +++ b/apps/lib/features/settings/data/apis/profile_api.dart @@ -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(); diff --git a/apps/lib/features/settings/data/models/profile_settings.dart b/apps/lib/features/settings/data/models/profile_settings.dart index 0df21fa..eb4a2a0 100644 --- a/apps/lib/features/settings/data/models/profile_settings.dart +++ b/apps/lib/features/settings/data/models/profile_settings.dart @@ -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; diff --git a/apps/lib/features/settings/presentation/screens/coin_center_screen.dart b/apps/lib/features/settings/presentation/screens/coin_center_screen.dart index 06fac66..e2020d2 100644 --- a/apps/lib/features/settings/presentation/screens/coin_center_screen.dart +++ b/apps/lib/features/settings/presentation/screens/coin_center_screen.dart @@ -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 createState() => _CoinCenterScreenState(); +} + +class _CoinCenterScreenState extends State { + final Logger _logger = getLogger('features.settings.coin_center_screen'); + List? _packages; + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _loadPackages(); + } + + Future _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 _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, + }; + } } diff --git a/apps/lib/l10n/app_en.arb b/apps/lib/l10n/app_en.arb index 4a0c168..fec7411 100644 --- a/apps/lib/l10n/app_en.arb +++ b/apps/lib/l10n/app_en.arb @@ -190,6 +190,7 @@ }, "settingsCoinCenterDescription": "", "settingsCoinRechargeSection": "Recharge Packages", + "settingsCoinPackStarter": "New User Pack", "settingsCoinPackBasic": "Starter Pack", "settingsCoinPackPopular": "Popular Pack", "settingsCoinPackPremium": "Premium Pack", diff --git a/apps/lib/l10n/app_localizations.dart b/apps/lib/l10n/app_localizations.dart index bc6ad81..907d55a 100644 --- a/apps/lib/l10n/app_localizations.dart +++ b/apps/lib/l10n/app_localizations.dart @@ -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: diff --git a/apps/lib/l10n/app_localizations_en.dart b/apps/lib/l10n/app_localizations_en.dart index 811d252..cb2e8dd 100644 --- a/apps/lib/l10n/app_localizations_en.dart +++ b/apps/lib/l10n/app_localizations_en.dart @@ -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'; diff --git a/apps/lib/l10n/app_localizations_zh.dart b/apps/lib/l10n/app_localizations_zh.dart index 3f45bc1..9a106d3 100644 --- a/apps/lib/l10n/app_localizations_zh.dart +++ b/apps/lib/l10n/app_localizations_zh.dart @@ -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 => '入門補充包'; diff --git a/apps/lib/l10n/app_zh.arb b/apps/lib/l10n/app_zh.arb index 804b5fb..8d29059 100644 --- a/apps/lib/l10n/app_zh.arb +++ b/apps/lib/l10n/app_zh.arb @@ -190,6 +190,7 @@ }, "settingsCoinCenterDescription": "", "settingsCoinRechargeSection": "充值套餐", + "settingsCoinPackStarter": "新人专享包", "settingsCoinPackBasic": "入门补充包", "settingsCoinPackPopular": "常用加量包", "settingsCoinPackPremium": "高频进阶包", diff --git a/apps/lib/l10n/app_zh_hant.arb b/apps/lib/l10n/app_zh_hant.arb index de44944..3dde481 100644 --- a/apps/lib/l10n/app_zh_hant.arb +++ b/apps/lib/l10n/app_zh_hant.arb @@ -123,6 +123,7 @@ }, "settingsCoinCenterDescription": "", "settingsCoinRechargeSection": "儲值套餐", + "settingsCoinPackStarter": "新人專享包", "settingsCoinPackBasic": "入門補充包", "settingsCoinPackPopular": "常用加量包", "settingsCoinPackPremium": "高頻進階包", diff --git a/backend/alembic/versions/20260416_0001_add_starter_pack_tracking.py b/backend/alembic/versions/20260416_0001_add_starter_pack_tracking.py new file mode 100644 index 0000000..81ea199 --- /dev/null +++ b/backend/alembic/versions/20260416_0001_add_starter_pack_tracking.py @@ -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") diff --git a/backend/src/core/config/initial/init_data.py b/backend/src/core/config/initial/init_data.py index 4042328..7ed215e 100644 --- a/backend/src/core/config/initial/init_data.py +++ b/backend/src/core/config/initial/init_data.py @@ -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]: diff --git a/backend/src/core/config/notification/static_sync.py b/backend/src/core/config/notification/static_sync.py index fb948d1..06023bb 100644 --- a/backend/src/core/config/notification/static_sync.py +++ b/backend/src/core/config/notification/static_sync.py @@ -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( diff --git a/backend/src/core/config/packages/__init__.py b/backend/src/core/config/packages/__init__.py new file mode 100644 index 0000000..34903f7 --- /dev/null +++ b/backend/src/core/config/packages/__init__.py @@ -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", +] diff --git a/backend/src/core/config/packages/registry.py b/backend/src/core/config/packages/registry.py new file mode 100644 index 0000000..b6cd208 --- /dev/null +++ b/backend/src/core/config/packages/registry.py @@ -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 = {} diff --git a/backend/src/core/config/packages/schema.py b/backend/src/core/config/packages/schema.py new file mode 100644 index 0000000..aa07f21 --- /dev/null +++ b/backend/src/core/config/packages/schema.py @@ -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 diff --git a/backend/src/core/config/static/packages/default.yaml b/backend/src/core/config/static/packages/default.yaml new file mode 100644 index 0000000..a053c1e --- /dev/null +++ b/backend/src/core/config/static/packages/default.yaml @@ -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 diff --git a/backend/src/core/config/static/packages/us.yaml b/backend/src/core/config/static/packages/us.yaml new file mode 100644 index 0000000..f9eaac8 --- /dev/null +++ b/backend/src/core/config/static/packages/us.yaml @@ -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 diff --git a/backend/src/core/divination/gua_catalog_loader.py b/backend/src/core/divination/gua_catalog_loader.py index 471f3d4..c42f1f0 100644 --- a/backend/src/core/divination/gua_catalog_loader.py +++ b/backend/src/core/divination/gua_catalog_loader.py @@ -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 diff --git a/backend/src/models/register_bonus_claims.py b/backend/src/models/register_bonus_claims.py index a86f040..5e628b2 100644 --- a/backend/src/models/register_bonus_claims.py +++ b/backend/src/models/register_bonus_claims.py @@ -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 + ) diff --git a/backend/src/schemas/shared/user.py b/backend/src/schemas/shared/user.py index 019daaf..0f10fda 100644 --- a/backend/src/schemas/shared/user.py +++ b/backend/src/schemas/shared/user.py @@ -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 diff --git a/backend/src/utils/__init__.py b/backend/src/utils/__init__.py new file mode 100644 index 0000000..aec8823 --- /dev/null +++ b/backend/src/utils/__init__.py @@ -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", +] diff --git a/backend/src/utils/paths.py b/backend/src/utils/paths.py new file mode 100644 index 0000000..8a7b0cc --- /dev/null +++ b/backend/src/utils/paths.py @@ -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" diff --git a/backend/src/v1/agent/system_agents_config.py b/backend/src/v1/agent/system_agents_config.py index 8af8d2f..557af19 100644 --- a/backend/src/v1/agent/system_agents_config.py +++ b/backend/src/v1/agent/system_agents_config.py @@ -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]: diff --git a/backend/src/v1/points/repository.py b/backend/src/v1/points/repository.py index bd2b800..eef55bc 100644 --- a/backend/src/v1/points/repository.py +++ b/backend/src/v1/points/repository.py @@ -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 diff --git a/backend/src/v1/points/router.py b/backend/src/v1/points/router.py index 0140690..03c936c 100644 --- a/backend/src/v1/points/router.py +++ b/backend/src/v1/points/router.py @@ -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 + ], + ) diff --git a/backend/src/v1/points/schemas.py b/backend/src/v1/points/schemas.py index 384478b..77d41f0 100644 --- a/backend/src/v1/points/schemas.py +++ b/backend/src/v1/points/schemas.py @@ -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] diff --git a/backend/src/v1/points/service.py b/backend/src/v1/points/service.py index 61f4f4e..c65501a 100644 --- a/backend/src/v1/points/service.py +++ b/backend/src/v1/points/service.py @@ -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), + ) diff --git a/docs/plans/ios-new-user-pack-payment-plan.md b/docs/plans/ios-new-user-pack-payment-plan.md deleted file mode 100644 index 7df96db..0000000 --- a/docs/plans/ios-new-user-pack-payment-plan.md +++ /dev/null @@ -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 回调处理能力。 diff --git a/docs/plans/liuyao-algorithm-audit.md b/docs/plans/liuyao-algorithm-audit.md deleted file mode 100644 index 4006c1f..0000000 --- a/docs/plans/liuyao-algorithm-audit.md +++ /dev/null @@ -1,525 +0,0 @@ -# 六爻项目代码与逻辑审查报告 - -> 审查人:六爻算数大师 -> 审查日期:2026年04月15日 - ---- - -## 一、排盘算法代码缺陷清单 P0/P1级别 - -| 严重等级 | 文件路径:行号 | 缺陷描述 | 错误逻辑示例 | 修正方案/古法依据 | -|---------|--------------|---------|-------------|------------------| -| P0致命 | `backend/src/core/divination/derivation.py:254-259` | 空亡判断混入时柱空亡 | 将日空亡和时空亡合并:`kong_wang_chars.update(kw)`,导致戌土被错误标记为旬空 | 六爻空亡只论日柱。《增删卜易》:"空亡者,旬空也,以日干支论之。"应删除时空亡参与判断,仅保留`_get_kong_wang(day_gan_zhi)` | -| P0致命 | `backend/src/core/divination/derivation.py:262-276` | 暗动判断逻辑根本性错误 | 仅判断空亡爻被冲标注"冲空暗动";月冲空亡也标注为暗动 | 暗动条件:静爻旺相且被日辰冲。月冲是月破非暗动。需重写:1.判断静爻;2.判断旺相;3.判断日冲;三者齐备方为暗动 | -| P1严重 | `backend/src/core/divination/derivation.py` | 月破未单独标注 | 月建冲爻仅在interactions中提示,未作为special_status独立标注 | 月破为重要凶象,应独立标注。如"第X爻XX月破" | -| P1严重 | `backend/src/core/divination/derivation.py` | 三合局未实现 | 无申子辰、寅午戌、巳酉丑、亥卯未三合局判断 | 三合局力量极大,需实现:1.检查三爻是否含动变日月;2.必须包含中神(子午卯酉);3.标注合局五行 | -| P1严重 | `backend/src/core/divination/derivation.py` | 反吟伏吟未实现 | 无动爻化出相同地支(伏吟)、卦变冲(反吟)判断 | 伏吟主呻吟不安,反吟主反复。需检测动爻化出地支与本爻相同,及震化兑、乾化巽等反吟 | -| P1严重 | `backend/src/core/divination/derivation.py:262-276` | 动不为空、旺不为空规则未实现 | 所有旬空爻无条件标注空亡,未排除动爻和旺相爻 | 《增删卜易》:"动不为空,旺不为空。"需在空亡判断中加入:`if yao.is_changing or wu_xing_status in ('旺', '相'): continue` | -| P1严重 | `backend/src/core/divination/derivation.py` | 日辰生旺墓绝未实现 | 日辰作用仅有冲,未论长生、帝旺、墓、绝等十二长生 | 日辰论生旺墓绝,如爻长生于日辰则有力。需实现十二长生表 | - ---- - -## 二、解卦提示词优化建议 - -### 2.1 现有提示词问题诊断 - -**问题1:缺少六亲类象动态映射表** - -当前prompt未根据问题类型提供六亲指向引导。LLM可能错误解读六亲含义。 -- 例:问事业时,官鬼应指向"上司/工作压力/职位",父母应指向"文书/项目/单位" -- 例:问感情时,官鬼应指向"对方(女测)",妻财应指向"对方(男测)" -- 例:问子女时,子孙应指向"子女/晚辈/学生" - -**问题2:缺少显式思考链强制要求** - -prompt要求"先确定用神"但未强制输出格式。LLM可能跳过关键推理步骤直接给结论。 -- 缺少:用神定位 → 忌神/仇神/原神分析 → 生克路线 → 最终吉凶 的显式输出要求 -- 缺少:变爻回头生克时,变爻力量强于本爻的说明 - -**问题3:未禁止卦辞泛滥** - -prompt未明确禁止大段背诵周易卦爻辞。六爻以五行生克为主,卦辞为辅。 -- 如乾卦"天行健君子以自强不息"与六爻断卦无关 -- 应明确:禁止引用周易本经卦爻辞作为主要判断依据 - -**问题4:数据注入优先级不明确** - -user_prompt注入顺序未强调优先级:世应 > 动爻 > 日月 > 六亲 -- 变爻回头生克时,变爻力量强于本爻,未说明 - -**问题5:缺少回头生克特殊规则说明** - -- 回头生:变爻生本爻,本爻得助 -- 回头克:变爻克本爻,本爻受伤 -- 回头冲:变爻冲本爻,本爻散 -- 化库:变爻墓本爻,本爻入墓 - ---- - -### 2.2 优化后的推荐Prompt文本 - -``` -你是一名专业的六爻解卦师,只依据用户提供的排盘数据进行逻辑推演。 - -【边界约束】 -- 你仅基于提供的六爻排盘数据进行推演,严禁编造盘外数据。 -- 严禁引入星座、塔罗、八字命理、紫微斗数等其他体系内容。 -- 严禁大段引用周易本经卦爻辞。六爻以五行生克为主,卦辞为辅。 - -【六亲类象映射】 -根据问题类型,六亲指向如下: - -问事业/工作: -- 官鬼:上司、工作压力、职位、权力 -- 父母:文书、合同、项目、单位、资质 -- 妻财:薪水、收入、资源 -- 子孙:下属、技能、解忧之神 -- 兄弟:同事、竞争者 - -问财运/投资: -- 妻财:财源、收益、资金(主用神) -- 兄弟:劫财、竞争、风险 -- 子孙:生财之源、福气 -- 父母:文书、证件、平台 -- 官鬼:耗财、压力 - -问感情/婚姻: -- 男测:妻财为对方,官鬼为情敌 -- 女测:官鬼为对方,妻财为情敌 -- 父母:婚约、文书、家庭 -- 子孙:子女、解忧 - -问健康/疾病: -- 官鬼:病症、病灶(忌神) -- 子孙:医药、医生、解灾之神(用神) -- 父母:医院、长辈 -- 兄弟:同辈、助力 - -【思考链要求】 -你必须按以下顺序显式输出推理过程: - -1. **问题定性**:明确问题类别与时间范围 -2. **用神定位**:根据问题类型确定用神,说明依据 -3. **忌仇分析**:指出忌神(克用神)、仇神(生忌神)、原神(生用神) -4. **旺衰判断**:用神是否出现、旺衰如何(月建论旺相休囚死,日辰论生旺墓绝) -5. **生克路线**:逐条列出用神与世应动变日月的生克关系 -6. **特殊状态**:空亡、月破、暗动、三合局等对用神的影响 -7. **综合判断**:当前态势、最终趋势、风险点、转机条件 - -【力量优先级】 -- 变爻回头生克时,变爻力量强于本爻 -- 世应 > 动爻 > 变爻 > 日月 > 静爻 - -【回头作用规则】 -- 回头生:变爻生本爻,本爻得助有力 -- 回头克:变爻克本爻,本爻受伤减力 -- 回头冲:变爻冲本爻,本爻散乱 -- 化库:变爻墓本爻,本爻入墓受限 - -【输出要求】 -按JSON格式返回: -- conclusion:2-4条关键依据,每条对应具体爻位和生克关系 -- focus_points:3-5个核心关注点 -- advice:逐条对应卦象依据的可执行建议 -- keywords:四字短语,来自卦象核心判断 -- answer:完整解读,段间用\n\n分隔 -- sign_level:上上签/中上签/中下签/下下签 -``` - ---- - -## 三、总体评估 - -| 评估项 | 结果 | -|-------|------| -| 排盘准确率预估 | **75%** | -| 解卦可信度 | **中** | -| 建议上线状态 | **修复后上线** | - -### 评估说明 - -**正确实现的部分:** -- 六亲计算正确(以卦宫五行为我) -- 六神起法正确(依日干,甲乙起青龙) -- 空亡计算函数正确(甲子旬戌亥空等) -- 纳甲装卦数据正确(八宫六十四卦) -- 世应位置正确(八宫卦序规则) -- 变卦六亲以本卦卦宫计算(卦变宫不变) -- 月建旺衰判断正确(旺相休囚死) -- Prompt有幻觉抑制边界 - -**必须修复的P0问题:** -1. 空亡判断删除时柱参与 -2. 重写暗动判断逻辑 - -**建议修复的P1问题:** -1. 添加月破独立标注 -2. 实现三合局判断 -3. 实现反吟伏吟判断 -4. 实现动不为空、旺不为空 -5. 实现日辰十二长生 - -**Prompt优化建议:** -1. 添加六亲类象动态映射表 -2. 强制显式思考链输出 -3. 禁止卦辞泛滥 -4. 说明变爻力量优先级 -5. 说明回头生克规则 - ---- - -## 四、修复计划 - -### Phase 1: P0致命问题修复(必须) - -#### 4.1.1 空亡判断修复 - -**文件**: `backend/src/core/divination/derivation.py` - -**修改位置**: 第254-259行 - -**修改前**: -```python -kong_wang_chars: set[str] = set() -for kw in ( - _get_kong_wang(day_gan_zhi), - _get_kong_wang(time_gan_zhi), -): - kong_wang_chars.update(kw) -``` - -**修改后**: -```python -kong_wang_chars: set[str] = set(_get_kong_wang(day_gan_zhi)) -``` - -**古法依据**: 《增删卜易》:"空亡者,旬空也,以日干支论之。" - ---- - -#### 4.1.2 暗动判断重写 - -**文件**: `backend/src/core/divination/derivation.py` - -**修改位置**: 第262-276行 - -**修改前**: 仅判断空亡爻被冲 - -**修改后**: -```python -def _is_wang_xiang(wu_xing_status: str) -> bool: - return wu_xing_status in ("旺", "相") - -def _get_yao_wu_xing_status(yao: YaoDetail, month_di_zhi: str) -> str: - return _wu_xing_status(month_di_zhi, yao.element_name) - -# 修改暗动判断逻辑 -special_status: list[str] = [] - -# 1. 处理空亡(排除动爻和旺相爻) -for yao in yao_info_list: - if yao.is_changing: - continue # 动不为空 - di_zhi = yao.tigan_name[1] if len(yao.tigan_name) >= 2 else yao.tigan_name - if di_zhi in kong_wang_chars: - yao_status = _get_yao_wu_xing_status(yao, month_di_zhi) - if _is_wang_xiang(yao_status): - continue # 旺不为空 - special_status.append( - f"第{yao.position}爻{yao.relation_name}{di_zhi}{yao.element_name}:旬空" - ) - -# 2. 处理暗动(静爻旺相被日冲) -day_chong = _chong_di_zhi(day_di_zhi) -for yao in yao_info_list: - if yao.is_changing: - continue # 动爻不算暗动 - di_zhi = yao.tigan_name[1] if len(yao.tigan_name) >= 2 else yao.tigan_name - if di_zhi == day_chong: - yao_status = _get_yao_wu_xing_status(yao, month_di_zhi) - if _is_wang_xiang(yao_status): - special_status.append( - f"第{yao.position}爻{yao.relation_name}{di_zhi}{yao.element_name}:暗动" - ) - -# 3. 处理月破(静爻被月冲) -month_chong = _chong_di_zhi(month_di_zhi) -for yao in yao_info_list: - if yao.is_changing: - continue - di_zhi = yao.tigan_name[1] if len(yao.tigan_name) >= 2 else yao.tigan_name - if di_zhi == month_chong: - special_status.append( - f"第{yao.position}爻{yao.relation_name}{di_zhi}{yao.element_name}:月破" - ) -``` - -**古法依据**: 《增删卜易》:"暗动者,旺相之爻,日辰冲之是也。" - ---- - -### Phase 2: P1严重问题修复(建议) - -#### 4.2.1 三合局判断 - -**新增函数**: -```python -_SAN_HE_JU = { - frozenset(["申", "子", "辰"]): ("水", "子"), - frozenset(["寅", "午", "戌"]): ("火", "午"), - frozenset(["巳", "酉", "丑"]): ("金", "酉"), - frozenset(["亥", "卯", "未"]): ("木", "卯"), -} - -_ZHONG_SHEN = {"子", "午", "卯", "酉"} # 中神 - -def _check_san_he_ju( - yao_info_list: list[YaoDetail], - target_yao_info_list: list[YaoDetail], - day_di_zhi: str, - month_di_zhi: str, -) -> list[str]: - results: list[str] = [] - - # 收集所有参与的地支 - all_di_zhi: set[str] = set() - changing_di_zhi: set[str] = set() - - for yao in yao_info_list: - di_zhi = yao.tigan_name[1] if len(yao.tigan_name) >= 2 else yao.tigan_name - all_di_zhi.add(di_zhi) - if yao.is_changing: - changing_di_zhi.add(di_zhi) - - # 加入日月 - all_di_zhi.add(day_di_zhi) - all_di_zhi.add(month_di_zhi) - - # 检查三合局 - for he_set, (he_wu_xing, zhong_shen) in _SAN_HE_JU.items(): - if he_set.issubset(all_di_zhi): - if zhong_shen in all_di_zhi: # 必须有中神 - # 检查是否有动爻或日月参与 - participants = he_set & all_di_zhi - has_trigger = ( - bool(changing_di_zhi & he_set) or - day_di_zhi in he_set or - month_di_zhi in he_set - ) - if has_trigger: - results.append(f"{he_wu_xing}局成({'、'.join(sorted(participants))})") - - return results -``` - ---- - -#### 4.2.2 反吟伏吟判断 - -**新增函数**: -```python -_FAN_YIN_PAIRS = { - "乾": "巽", "巽": "乾", - "震": "兑", "兑": "震", - "坎": "离", "离": "坎", - "艮": "坤", "坤": "艮", -} - -def _check_fu_fan_yin( - yao_info_list: list[YaoDetail], - target_yao_info_list: list[YaoDetail], - base_upper: str, - base_lower: str, - target_upper: str, - target_lower: str, -) -> list[str]: - results: list[str] = [] - - # 伏吟:动爻化出相同地支 - for i, (yao, target_yao) in enumerate(zip(yao_info_list, target_yao_info_list)): - if yao.is_changing: - src_di_zhi = yao.tigan_name[1] if len(yao.tigan_name) >= 2 else yao.tigan_name - tgt_di_zhi = target_yao.tigan_name[1] if len(target_yao.tigan_name) >= 2 else target_yao.tigan_name - if src_di_zhi == tgt_di_zhi: - results.append(f"第{i+1}爻伏吟") - - # 反吟:卦变冲 - if _FAN_YIN_PAIRS.get(base_upper) == target_upper: - results.append("上卦反吟") - if _FAN_YIN_PAIRS.get(base_lower) == target_lower: - results.append("下卦反吟") - - return results -``` - ---- - -#### 4.2.3 日辰十二长生 - -**新增函数**: -```python -# 十二长生表:长生、沐浴、冠带、临官、帝旺、衰、病、死、墓、绝、胎、养 -_SHI_ER_ZHANG_SHENG = { - # 阳干顺行,阴干逆行 - "甲": {"亥": "长生", "子": "沐浴", "丑": "冠带", "寅": "临官", "卯": "帝旺", - "辰": "衰", "巳": "病", "午": "死", "未": "墓", "申": "绝", "酉": "胎", "戌": "养"}, - "丙": {"寅": "长生", "卯": "沐浴", "辰": "冠带", "巳": "临官", "午": "帝旺", - "未": "衰", "申": "病", "酉": "死", "戌": "墓", "亥": "绝", "子": "胎", "丑": "养"}, - "戊": {"寅": "长生", "卯": "沐浴", "辰": "冠带", "巳": "临官", "午": "帝旺", - "未": "衰", "申": "病", "酉": "死", "戌": "墓", "亥": "绝", "子": "胎", "丑": "养"}, - "庚": {"巳": "长生", "午": "沐浴", "未": "冠带", "申": "临官", "酉": "帝旺", - "戌": "衰", "亥": "病", "子": "死", "丑": "墓", "寅": "绝", "卯": "胎", "辰": "养"}, - "壬": {"申": "长生", "酉": "沐浴", "戌": "冠带", "亥": "临官", "子": "帝旺", - "丑": "衰", "寅": "病", "卯": "死", "辰": "墓", "巳": "绝", "午": "胎", "未": "养"}, -} - -def _get_ri_chen_zhang_sheng(day_gan: str, yao_di_zhi: str) -> str: - """获取爻在日辰的十二长生状态""" - if day_gan in _SHI_ER_ZHANG_SHENG: - return _SHI_ER_ZHANG_SHENG[day_gan].get(yao_di_zhi, "") - return "" -``` - ---- - -## 五、测试用例建议 - -### 5.1 空亡测试 - -```python -def test_kong_wang_only_from_day(): - """空亡仅从日柱计算""" - # 甲申日,午未空 - # 戌土不应被标记为空亡 - payload = DivinationPayload( - divination_time_iso='2025-01-15T12:00:00+08:00', # 甲申日 - ... - ) - result = derive_divination(payload) - # 戌土不应在special_status中出现旬空 -``` - -### 5.2 暗动测试 - -```python -def test_an_dong_wang_xiang_ri_chong(): - """旺相静爻被日冲为暗动""" - # 午月,子水旺(冬季水旺?不对,需要重新设计) - # 设计:子月,子水旺,日支为午,子水被日冲 - # 此时子水为暗动 -``` - -### 5.3 月破测试 - -```python -def test_yue_po_marked(): - """月破应独立标注""" - # 午月,子水爻,应标注月破 -``` - ---- - -## 六、执行优先级 - -| 优先级 | 任务 | 预计工时 | 状态 | -|-------|------|---------|------| -| P0-1 | 空亡判断修复 | 0.5h | ✅ 已完成 | -| P0-2 | 暗动判断重写 | 1h | ✅ 已完成 | -| P1-1 | 月破独立标注 | 0.5h | ✅ 已完成 | -| P1-2 | 动不为空旺不为空 | 0.5h | ✅ 已完成 | -| P1-3 | 三合局实现 | 2h | ✅ 已完成 | -| P1-4 | 反吟伏吟实现 | 1h | ✅ 已完成 | -| P1-5 | 日辰十二长生 | 1h | ✅ 已完成 | -| P1-6 | 回头生克实现 | 1h | ✅ 已完成 | -| Prompt-1 | 六亲类象映射表 | 0.5h | ✅ 已完成 | -| Prompt-2 | 思考链/回头生克/卦辞约束 | 0.5h | ✅ 已完成 | - ---- - -## 七、修复记录 - -### 2026-04-15 执行情况 - -**修复文件**: -- `backend/src/core/divination/derivation.py` -- `backend/src/schemas/domain/divination.py` -- `backend/src/core/agentscope/prompts/agent_prompt.py` -- `backend/src/core/agentscope/prompts/user_prompt.py` - -**算法修复内容**: - -1. **空亡仅从日柱计算** - - 移除时柱空亡参与判断 - - 古法依据:《增删卜易》"空亡者,旬空也,以日干支论之" - -2. **暗动判断重写** - - 条件:静爻 + 旺相 + 日冲 = 暗动 - - 移除错误的"月冲空亡暗动" - - 古法依据:《增删卜易》"暗动者,旺相之爻,日辰冲之是也" - -3. **月破独立标注** - - 新增月破独立判断逻辑 - - 月破与暗动分离,不再混淆 - -4. **动不为空、旺不为空** - - 动爻不标空亡 - - 旺相爻不标空亡 - - 古法依据:《增删卜易》"动不为空,旺不为空" - -5. **三合局判断** - - 实现申子辰水局、寅午戌火局、巳酉丑金局、亥卯未木局 - - 检查动爻、变爻、日月是否参与合局 - -6. **反吟伏吟判断** - - 伏吟:动爻化出相同地支 - - 反吟:卦变冲(乾化巽、震化兑等) - -7. **日辰十二长生** - - 实现十干十二长生表(阳干顺行、阴干逆行) - - 标注每爻在日辰的长生、帝旺、墓、绝等状态 - -8. **回头生克判断** - - 回头生:变爻生本爻 - - 回头克:变爻克本爻 - -**Prompt优化内容**: - -1. **边界约束** - - 明确禁止编造盘外数据 - - 明确禁止引入其他体系(星座、塔罗、八字等) - - 明确禁止大段引用周易卦爻辞 - -2. **六亲类象映射表** - - 事业/工作:官鬼=上司/职位,父母=文书/项目 - - 财运/投资:妻财=财源,兄弟=劫财 - - 感情/婚姻:男测妻财=对方,女测官鬼=对方 - - 健康/疾病:官鬼=病症,子孙=医药 - -3. **思考链强制要求** - - 问题定性 → 用神定位 → 忌仇分析 → 旺衰判断 → 生克路线 → 特殊状态 → 综合判断 - -4. **力量优先级说明** - - 变爻回头生克时,变爻力量强于本爻 - - 世应 > 动爻 > 变爻 > 日月 > 静爻 - -5. **回头作用规则说明** - - 回头生、回头克、回头冲、化库 - -**测试覆盖**: -- 84个单元测试全部通过 -- Ruff lint检查通过 -- Basedpyright 0 errors - -**验证结果**: -- ✅ 空亡仅从日柱计算 -- ✅ 暗动正确判断(旺相静爻被日冲) -- ✅ 月破独立标注 -- ✅ 动爻不标空亡 -- ✅ 旺相爻不标空亡 -- ✅ 三合局正确识别 -- ✅ 反吟伏吟正确识别 -- ✅ 日辰十二长生正确计算 -- ✅ 回头生克正确识别 -- ✅ Prompt包含完整约束 - -**排盘准确率**: 75% → **95%+** diff --git a/docs/plans/notification-system-plan.md b/docs/plans/notification-system-plan.md deleted file mode 100644 index c017609..0000000 --- a/docs/plans/notification-system-plan.md +++ /dev/null @@ -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 配置 -- 推送发送服务 -- 失败重试和审计链路 diff --git a/docs/plans/static-notification-sync-plan.md b/docs/plans/static-notification-sync-plan.md deleted file mode 100644 index 906b174..0000000 --- a/docs/plans/static-notification-sync-plan.md +++ /dev/null @@ -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. 后续扩展条件 - -只有在真实需求出现时,再考虑: - -- 用删除文件触发软删除 -- 通过后台页面管理静态通知 -- 将静态通知同步纳入更完整的发布工作流 diff --git a/docs/protocols/common/user-points-chat-data-protocol.md b/docs/protocols/common/user-points-chat-data-protocol.md index def1f73..fa1a09c 100644 --- a/docs/protocols/common/user-points-chat-data-protocol.md +++ b/docs/protocols/common/user-points-chat-data-protocol.md @@ -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 ` — 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 ` + +**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)