feat: 新人初始礼包购买追踪功能

- 数据库:添加 has_purchased_starter_pack 字段到 register_bonus_claims
- 后端:创建静态配置管理套餐信息,支持按国家/地区区分
- 后端:新增 GET /api/v1/points/packages API 返回可用套餐
- 后端:创建 utils/paths.py 统一路径管理
- 前端:动态获取套餐信息,移除硬编码
- 前端:添加 ProductCode 枚举约束,前后端类型安全
- 配置:Profile 默认国家改为 US(ISO 3166-1 alpha-2)
- 文档:更新协议文档说明新 API 和字段
This commit is contained in:
qzl
2026-04-16 16:11:09 +08:00
parent 443c0c80ae
commit ff40ff9dd8
38 changed files with 1434 additions and 2517 deletions
@@ -1,247 +0,0 @@
# iOS 新人包支付接入与一次性权益计划
## 1. 背景与目标
当前前端充值页为静态套餐展示,购买按钮未接入真实支付链路。现需新增 iOS 新人包:
- 价格:`$0.99`
- 积分:`60`
- 资格:同邮箱只能购买一次
- 删除账号后同邮箱重新注册,不刷新新人包资格
同时补齐后端真实支付路由与订单审计能力,前端不再硬编码套餐。
## 2. 本次范围
### 2.1 In Scope
1. 后端新增 iOS 支付相关路由(下单/验单/查询/回调)。
2. 新建支付订单主表与支付事件审计表。
3. 改造 `register_bonus_claims` 为可承载“权益唯一占用”能力。
4. 前端套餐由后端接口驱动,不再硬编码三档固定套餐。
5. 新人包资格前后端联动(展示、购买、验单、入账)。
### 2.2 Out of Scope
1. Android 支付渠道接入。
2. Apple 开发者账号正式联调(当前账号未就绪)。
3. 财务对账后台页面。
## 3. 数据模型设计
## 3.1 新建表:`payment_orders`
用途:订单当前态,支持幂等验单与退款状态跟踪。
建议字段:
- `id` UUID PK
- `order_no` VARCHAR(64) UNIQUE
- `user_id` UUID NOT NULL (`auth.users.id`)
- `channel` VARCHAR(16) NOT NULL (`ios_iap`)
- `product_code` VARCHAR(64) NOT NULL(例:`new_user_pack_099_60`
- `price_usd` NUMERIC(12,6) NOT NULL
- `credits` BIGINT NOT NULL
- `currency` VARCHAR(8) NOT NULL DEFAULT `USD`
- `status` VARCHAR(24) NOT NULL
- `created|receipt_submitted|verified|credited|refund_pending|refunded|revoked|failed`
- `apple_transaction_id` VARCHAR(128) NULL UNIQUE
- `apple_original_transaction_id` VARCHAR(128) NULL
- `app_account_token` UUID NULL
- `idempotency_key` VARCHAR(128) NULL UNIQUE
- `error_code` VARCHAR(64) NULL
- `error_message` TEXT NULL
- `created_at` / `updated_at`
关键约束:
- `credits > 0`
- `price_usd >= 0`
- `status` check
- `channel='ios_iap'`(本期)
## 3.2 新建表:`payment_order_events`
用途:支付事件不可变审计流水(验单结果、回调、退款、冲正)。
建议字段:
- `id` UUID PK
- `order_id` UUID NOT NULL FK `payment_orders.id`
- `event_type` VARCHAR(32) NOT NULL
- `order_created|receipt_submitted|verify_success|verify_failed|credited|refund_notified|refunded|revoke_notified|reversed`
- `event_source` VARCHAR(24) NOT NULL
- `api|apple_server_notification|job`
- `event_idempotency_key` VARCHAR(128) NULL UNIQUE
- `payload` JSONB NOT NULL
- `operator_id` UUID NULL
- `created_at`
## 3.3 改造表:`register_bonus_claims`
目标:从“注册送分去重”升级为“权益唯一占用”。
新增字段建议:
- `offer_code` VARCHAR(64) NOT NULL(例:`register_bonus_20``new_user_pack_099_60`
- `claim_source` VARCHAR(24) NOT NULL`register_bonus|ios_purchase`
- `claim_order_id` UUID NULL FK `payment_orders.id`
新增唯一约束:
- `UNIQUE(offer_code, email_hash)`
保留行为:
- `first_user_id` 允许 `ON DELETE SET NULL`,保证删号后资格仍占用。
## 4. 路由与服务边界
## 4.1 后端新增路由(v1
1. `GET /api/v1/payments/packages`
- 返回可购买套餐列表与用户资格(是否可买新人包)。
2. `POST /api/v1/payments/orders`
- 创建订单,返回 `orderNo` 与客户端支付所需参数。
3. `POST /api/v1/payments/orders/{orderNo}/verify-ios-receipt`
- 提交 iOS 收据,后端调用 Apple 校验。
4. `GET /api/v1/payments/orders/{orderNo}`
- 查询订单状态与入账结果。
5. `POST /api/v1/payments/webhooks/apple`
- 接收 App Store Server Notifications V2,处理退款/撤销。
## 4.2 分层职责
- Router:鉴权、请求校验、RFC7807 错误映射。
- Service
- 资格判断(新人包是否可买)
- 下单与验单业务编排
- 入账积分与冲正
- 幂等控制
- Repository
- `payment_orders`/`payment_order_events`/`register_bonus_claims` 读写
- 订单状态流转条件更新
## 5. 核心流程
## 5.1 下单与资格检查
```text
客户端请求套餐 -> GET /payments/packages
-> 后端按 email_hash 检查 offer_code='new_user_pack_099_60' 是否已占用
-> 返回 eligible=true/false
客户端创建订单 -> POST /payments/orders
-> 再次做资格校验(防并发)
-> 创建 payment_orders(status=created)
-> 写 payment_order_events(order_created)
```
## 5.2 iOS 验单与积分入账
```text
客户端支付后提交 receipt -> POST /orders/{orderNo}/verify-ios-receipt
-> 后端调用 Apple 验单(可切 sandbox
-> 验证 transaction_id 幂等
-> 状态 verified
-> 原子事务:
1) 占用权益 register_bonus_claims(offer_code,email_hash)
2) 写 points_ledger(grant)
3) 写 points_audit_ledger(direction=1,billed_to='user')
4) 订单置 credited
5) 写 payment_order_events(credited)
```
## 5.3 退款与冲正
```text
Apple 回调退款 -> POST /payments/webhooks/apple
-> 定位 order(transaction_id / original_transaction_id)
-> 幂等处理通知
-> 状态 refunded/revoked
-> 原子事务:
1) 写 points_ledger(adjust/consume reverse)
2) 写 points_audit_ledger(direction=-1,billed_to='platform',metadata.reason='refund')
3) 写 payment_order_events(refunded/reversed)
```
## 6. 信任边界与风控
1. 客户端价格、积分、product_code 全部不可信,按后端配置为准。
2. 不信任客户端“支付成功”标记,必须后端验单通过才入账。
3. Apple 回调需验签(JWS)并做 `notificationUUID` 幂等。
4. 订单与入账使用数据库事务,失败不允许半成功。
5. `offer_code + email_hash` 唯一约束是最终防线。
## 7. 前端改造
当前 `CoinCenterScreen` 中套餐硬编码,需改为 API 驱动:
- 页面加载调用 `GET /api/v1/payments/packages`
- 渲染返回的套餐列表
- 新人包 `eligible=false` 时展示“已购买/不可购买”态
- 点击购买后走真实支付流(创建订单 -> 拉起 IAP -> 提交 receipt
## 8. 无 Apple 账号阶段的交付策略
在无开发者账号前,先做可替换的验单适配层:
- `IOSReceiptVerifier` 接口(生产实现 + mock 实现)
- 通过配置开关使用 mock 结果跑通后端链路与前端状态
- 后续只替换 verifier 实现,不改订单主流程
## 9. 测试计划
## 9.1 后端单元测试
1. 新人包资格判定(首次可买、重复不可买、删号重注册不可买)
2. 验单幂等(同 transaction_id 不重复入账)
3. 退款冲正幂等(同通知不重复冲正)
## 9.2 后端集成测试
1. 首次注册 -> 下单 -> 验单 -> 入账 60
2. 删除账号 -> 同邮箱重注册 -> 新人包不可买
3. 退款通知 -> 积分冲正 -> 订单状态更新
## 9.3 前端集成测试
1. 套餐接口渲染(替代硬编码)
2. 新人包可买/不可买状态切换
3. 支付中/成功/失败/退款状态展示
## 10. 里程碑拆分
### PR1(数据层)
- 迁移:新建 `payment_orders``payment_order_events`
- 迁移:改造 `register_bonus_claims`
- 模型与 repository
### PR2(后端业务)
- 支付路由 + service
- iOS 验单适配层(先 mock
- 订单与积分入账/冲正
### PR3(前端)
- 套餐改 API 驱动
- 新人包购买态与禁用态
- 下单/验单交互链路
### PR4(联调与验证)
- 使用集成测试回归全流程
- Apple 账号就绪后切换真实 verifier
## 11. 变更类型判定
这是 **新 Feature**,不是现有功能的小修补。
理由:
1. 引入了新的支付域模型和事件审计。
2. 引入了新的后端支付路由与验单流程。
3. 前端从静态展示升级为可交易流程。
4. 增加了退款冲正与 iOS 回调处理能力。
-525
View File
@@ -1,525 +0,0 @@
# 六爻项目代码与逻辑审查报告
> 审查人:六爻算数大师
> 审查日期:2026年04月15日
---
## 一、排盘算法代码缺陷清单 P0/P1级别
| 严重等级 | 文件路径:行号 | 缺陷描述 | 错误逻辑示例 | 修正方案/古法依据 |
|---------|--------------|---------|-------------|------------------|
| P0致命 | `backend/src/core/divination/derivation.py:254-259` | 空亡判断混入时柱空亡 | 将日空亡和时空亡合并:`kong_wang_chars.update(kw)`,导致戌土被错误标记为旬空 | 六爻空亡只论日柱。《增删卜易》:"空亡者,旬空也,以日干支论之。"应删除时空亡参与判断,仅保留`_get_kong_wang(day_gan_zhi)` |
| P0致命 | `backend/src/core/divination/derivation.py:262-276` | 暗动判断逻辑根本性错误 | 仅判断空亡爻被冲标注"冲空暗动";月冲空亡也标注为暗动 | 暗动条件:静爻旺相且被日辰冲。月冲是月破非暗动。需重写:1.判断静爻;2.判断旺相;3.判断日冲;三者齐备方为暗动 |
| P1严重 | `backend/src/core/divination/derivation.py` | 月破未单独标注 | 月建冲爻仅在interactions中提示,未作为special_status独立标注 | 月破为重要凶象,应独立标注。如"第X爻XX月破" |
| P1严重 | `backend/src/core/divination/derivation.py` | 三合局未实现 | 无申子辰、寅午戌、巳酉丑、亥卯未三合局判断 | 三合局力量极大,需实现:1.检查三爻是否含动变日月;2.必须包含中神(子午卯酉);3.标注合局五行 |
| P1严重 | `backend/src/core/divination/derivation.py` | 反吟伏吟未实现 | 无动爻化出相同地支(伏吟)、卦变冲(反吟)判断 | 伏吟主呻吟不安,反吟主反复。需检测动爻化出地支与本爻相同,及震化兑、乾化巽等反吟 |
| P1严重 | `backend/src/core/divination/derivation.py:262-276` | 动不为空、旺不为空规则未实现 | 所有旬空爻无条件标注空亡,未排除动爻和旺相爻 | 《增删卜易》:"动不为空,旺不为空。"需在空亡判断中加入:`if yao.is_changing or wu_xing_status in ('旺', '相'): continue` |
| P1严重 | `backend/src/core/divination/derivation.py` | 日辰生旺墓绝未实现 | 日辰作用仅有冲,未论长生、帝旺、墓、绝等十二长生 | 日辰论生旺墓绝,如爻长生于日辰则有力。需实现十二长生表 |
---
## 二、解卦提示词优化建议
### 2.1 现有提示词问题诊断
**问题1:缺少六亲类象动态映射表**
当前prompt未根据问题类型提供六亲指向引导。LLM可能错误解读六亲含义。
- 例:问事业时,官鬼应指向"上司/工作压力/职位",父母应指向"文书/项目/单位"
- 例:问感情时,官鬼应指向"对方(女测)",妻财应指向"对方(男测)"
- 例:问子女时,子孙应指向"子女/晚辈/学生"
**问题2:缺少显式思考链强制要求**
prompt要求"先确定用神"但未强制输出格式。LLM可能跳过关键推理步骤直接给结论。
- 缺少:用神定位 → 忌神/仇神/原神分析 → 生克路线 → 最终吉凶 的显式输出要求
- 缺少:变爻回头生克时,变爻力量强于本爻的说明
**问题3:未禁止卦辞泛滥**
prompt未明确禁止大段背诵周易卦爻辞。六爻以五行生克为主,卦辞为辅。
- 如乾卦"天行健君子以自强不息"与六爻断卦无关
- 应明确:禁止引用周易本经卦爻辞作为主要判断依据
**问题4:数据注入优先级不明确**
user_prompt注入顺序未强调优先级:世应 > 动爻 > 日月 > 六亲
- 变爻回头生克时,变爻力量强于本爻,未说明
**问题5:缺少回头生克特殊规则说明**
- 回头生:变爻生本爻,本爻得助
- 回头克:变爻克本爻,本爻受伤
- 回头冲:变爻冲本爻,本爻散
- 化库:变爻墓本爻,本爻入墓
---
### 2.2 优化后的推荐Prompt文本
```
你是一名专业的六爻解卦师,只依据用户提供的排盘数据进行逻辑推演。
【边界约束】
- 你仅基于提供的六爻排盘数据进行推演,严禁编造盘外数据。
- 严禁引入星座、塔罗、八字命理、紫微斗数等其他体系内容。
- 严禁大段引用周易本经卦爻辞。六爻以五行生克为主,卦辞为辅。
【六亲类象映射】
根据问题类型,六亲指向如下:
问事业/工作:
- 官鬼:上司、工作压力、职位、权力
- 父母:文书、合同、项目、单位、资质
- 妻财:薪水、收入、资源
- 子孙:下属、技能、解忧之神
- 兄弟:同事、竞争者
问财运/投资:
- 妻财:财源、收益、资金(主用神)
- 兄弟:劫财、竞争、风险
- 子孙:生财之源、福气
- 父母:文书、证件、平台
- 官鬼:耗财、压力
问感情/婚姻:
- 男测:妻财为对方,官鬼为情敌
- 女测:官鬼为对方,妻财为情敌
- 父母:婚约、文书、家庭
- 子孙:子女、解忧
问健康/疾病:
- 官鬼:病症、病灶(忌神)
- 子孙:医药、医生、解灾之神(用神)
- 父母:医院、长辈
- 兄弟:同辈、助力
【思考链要求】
你必须按以下顺序显式输出推理过程:
1. **问题定性**:明确问题类别与时间范围
2. **用神定位**:根据问题类型确定用神,说明依据
3. **忌仇分析**:指出忌神(克用神)、仇神(生忌神)、原神(生用神)
4. **旺衰判断**:用神是否出现、旺衰如何(月建论旺相休囚死,日辰论生旺墓绝)
5. **生克路线**:逐条列出用神与世应动变日月的生克关系
6. **特殊状态**:空亡、月破、暗动、三合局等对用神的影响
7. **综合判断**:当前态势、最终趋势、风险点、转机条件
【力量优先级】
- 变爻回头生克时,变爻力量强于本爻
- 世应 > 动爻 > 变爻 > 日月 > 静爻
【回头作用规则】
- 回头生:变爻生本爻,本爻得助有力
- 回头克:变爻克本爻,本爻受伤减力
- 回头冲:变爻冲本爻,本爻散乱
- 化库:变爻墓本爻,本爻入墓受限
【输出要求】
按JSON格式返回:
- conclusion:2-4条关键依据,每条对应具体爻位和生克关系
- focus_points3-5个核心关注点
- advice:逐条对应卦象依据的可执行建议
- keywords:四字短语,来自卦象核心判断
- answer:完整解读,段间用\n\n分隔
- sign_level:上上签/中上签/中下签/下下签
```
---
## 三、总体评估
| 评估项 | 结果 |
|-------|------|
| 排盘准确率预估 | **75%** |
| 解卦可信度 | **中** |
| 建议上线状态 | **修复后上线** |
### 评估说明
**正确实现的部分:**
- 六亲计算正确(以卦宫五行为我)
- 六神起法正确(依日干,甲乙起青龙)
- 空亡计算函数正确(甲子旬戌亥空等)
- 纳甲装卦数据正确(八宫六十四卦)
- 世应位置正确(八宫卦序规则)
- 变卦六亲以本卦卦宫计算(卦变宫不变)
- 月建旺衰判断正确(旺相休囚死)
- Prompt有幻觉抑制边界
**必须修复的P0问题:**
1. 空亡判断删除时柱参与
2. 重写暗动判断逻辑
**建议修复的P1问题:**
1. 添加月破独立标注
2. 实现三合局判断
3. 实现反吟伏吟判断
4. 实现动不为空、旺不为空
5. 实现日辰十二长生
**Prompt优化建议:**
1. 添加六亲类象动态映射表
2. 强制显式思考链输出
3. 禁止卦辞泛滥
4. 说明变爻力量优先级
5. 说明回头生克规则
---
## 四、修复计划
### Phase 1: P0致命问题修复(必须)
#### 4.1.1 空亡判断修复
**文件**: `backend/src/core/divination/derivation.py`
**修改位置**: 第254-259行
**修改前**:
```python
kong_wang_chars: set[str] = set()
for kw in (
_get_kong_wang(day_gan_zhi),
_get_kong_wang(time_gan_zhi),
):
kong_wang_chars.update(kw)
```
**修改后**:
```python
kong_wang_chars: set[str] = set(_get_kong_wang(day_gan_zhi))
```
**古法依据**: 《增删卜易》:"空亡者,旬空也,以日干支论之。"
---
#### 4.1.2 暗动判断重写
**文件**: `backend/src/core/divination/derivation.py`
**修改位置**: 第262-276行
**修改前**: 仅判断空亡爻被冲
**修改后**:
```python
def _is_wang_xiang(wu_xing_status: str) -> bool:
return wu_xing_status in ("", "")
def _get_yao_wu_xing_status(yao: YaoDetail, month_di_zhi: str) -> str:
return _wu_xing_status(month_di_zhi, yao.element_name)
# 修改暗动判断逻辑
special_status: list[str] = []
# 1. 处理空亡(排除动爻和旺相爻)
for yao in yao_info_list:
if yao.is_changing:
continue # 动不为空
di_zhi = yao.tigan_name[1] if len(yao.tigan_name) >= 2 else yao.tigan_name
if di_zhi in kong_wang_chars:
yao_status = _get_yao_wu_xing_status(yao, month_di_zhi)
if _is_wang_xiang(yao_status):
continue # 旺不为空
special_status.append(
f"{yao.position}{yao.relation_name}{di_zhi}{yao.element_name}:旬空"
)
# 2. 处理暗动(静爻旺相被日冲)
day_chong = _chong_di_zhi(day_di_zhi)
for yao in yao_info_list:
if yao.is_changing:
continue # 动爻不算暗动
di_zhi = yao.tigan_name[1] if len(yao.tigan_name) >= 2 else yao.tigan_name
if di_zhi == day_chong:
yao_status = _get_yao_wu_xing_status(yao, month_di_zhi)
if _is_wang_xiang(yao_status):
special_status.append(
f"{yao.position}{yao.relation_name}{di_zhi}{yao.element_name}:暗动"
)
# 3. 处理月破(静爻被月冲)
month_chong = _chong_di_zhi(month_di_zhi)
for yao in yao_info_list:
if yao.is_changing:
continue
di_zhi = yao.tigan_name[1] if len(yao.tigan_name) >= 2 else yao.tigan_name
if di_zhi == month_chong:
special_status.append(
f"{yao.position}{yao.relation_name}{di_zhi}{yao.element_name}:月破"
)
```
**古法依据**: 《增删卜易》:"暗动者,旺相之爻,日辰冲之是也。"
---
### Phase 2: P1严重问题修复(建议)
#### 4.2.1 三合局判断
**新增函数**:
```python
_SAN_HE_JU = {
frozenset(["", "", ""]): ("", ""),
frozenset(["", "", ""]): ("", ""),
frozenset(["", "", ""]): ("", ""),
frozenset(["", "", ""]): ("", ""),
}
_ZHONG_SHEN = {"", "", "", ""} # 中神
def _check_san_he_ju(
yao_info_list: list[YaoDetail],
target_yao_info_list: list[YaoDetail],
day_di_zhi: str,
month_di_zhi: str,
) -> list[str]:
results: list[str] = []
# 收集所有参与的地支
all_di_zhi: set[str] = set()
changing_di_zhi: set[str] = set()
for yao in yao_info_list:
di_zhi = yao.tigan_name[1] if len(yao.tigan_name) >= 2 else yao.tigan_name
all_di_zhi.add(di_zhi)
if yao.is_changing:
changing_di_zhi.add(di_zhi)
# 加入日月
all_di_zhi.add(day_di_zhi)
all_di_zhi.add(month_di_zhi)
# 检查三合局
for he_set, (he_wu_xing, zhong_shen) in _SAN_HE_JU.items():
if he_set.issubset(all_di_zhi):
if zhong_shen in all_di_zhi: # 必须有中神
# 检查是否有动爻或日月参与
participants = he_set & all_di_zhi
has_trigger = (
bool(changing_di_zhi & he_set) or
day_di_zhi in he_set or
month_di_zhi in he_set
)
if has_trigger:
results.append(f"{he_wu_xing}局成({''.join(sorted(participants))}")
return results
```
---
#### 4.2.2 反吟伏吟判断
**新增函数**:
```python
_FAN_YIN_PAIRS = {
"": "", "": "",
"": "", "": "",
"": "", "": "",
"": "", "": "",
}
def _check_fu_fan_yin(
yao_info_list: list[YaoDetail],
target_yao_info_list: list[YaoDetail],
base_upper: str,
base_lower: str,
target_upper: str,
target_lower: str,
) -> list[str]:
results: list[str] = []
# 伏吟:动爻化出相同地支
for i, (yao, target_yao) in enumerate(zip(yao_info_list, target_yao_info_list)):
if yao.is_changing:
src_di_zhi = yao.tigan_name[1] if len(yao.tigan_name) >= 2 else yao.tigan_name
tgt_di_zhi = target_yao.tigan_name[1] if len(target_yao.tigan_name) >= 2 else target_yao.tigan_name
if src_di_zhi == tgt_di_zhi:
results.append(f"{i+1}爻伏吟")
# 反吟:卦变冲
if _FAN_YIN_PAIRS.get(base_upper) == target_upper:
results.append("上卦反吟")
if _FAN_YIN_PAIRS.get(base_lower) == target_lower:
results.append("下卦反吟")
return results
```
---
#### 4.2.3 日辰十二长生
**新增函数**:
```python
# 十二长生表:长生、沐浴、冠带、临官、帝旺、衰、病、死、墓、绝、胎、养
_SHI_ER_ZHANG_SHENG = {
# 阳干顺行,阴干逆行
"": {"": "长生", "": "沐浴", "": "冠带", "": "临官", "": "帝旺",
"": "", "": "", "": "", "": "", "": "", "": "", "": ""},
"": {"": "长生", "": "沐浴", "": "冠带", "": "临官", "": "帝旺",
"": "", "": "", "": "", "": "", "": "", "": "", "": ""},
"": {"": "长生", "": "沐浴", "": "冠带", "": "临官", "": "帝旺",
"": "", "": "", "": "", "": "", "": "", "": "", "": ""},
"": {"": "长生", "": "沐浴", "": "冠带", "": "临官", "": "帝旺",
"": "", "": "", "": "", "": "", "": "", "": "", "": ""},
"": {"": "长生", "": "沐浴", "": "冠带", "": "临官", "": "帝旺",
"": "", "": "", "": "", "": "", "": "", "": "", "": ""},
}
def _get_ri_chen_zhang_sheng(day_gan: str, yao_di_zhi: str) -> str:
"""获取爻在日辰的十二长生状态"""
if day_gan in _SHI_ER_ZHANG_SHENG:
return _SHI_ER_ZHANG_SHENG[day_gan].get(yao_di_zhi, "")
return ""
```
---
## 五、测试用例建议
### 5.1 空亡测试
```python
def test_kong_wang_only_from_day():
"""空亡仅从日柱计算"""
# 甲申日,午未空
# 戌土不应被标记为空亡
payload = DivinationPayload(
divination_time_iso='2025-01-15T12:00:00+08:00', # 甲申日
...
)
result = derive_divination(payload)
# 戌土不应在special_status中出现旬空
```
### 5.2 暗动测试
```python
def test_an_dong_wang_xiang_ri_chong():
"""旺相静爻被日冲为暗动"""
# 午月,子水旺(冬季水旺?不对,需要重新设计)
# 设计:子月,子水旺,日支为午,子水被日冲
# 此时子水为暗动
```
### 5.3 月破测试
```python
def test_yue_po_marked():
"""月破应独立标注"""
# 午月,子水爻,应标注月破
```
---
## 六、执行优先级
| 优先级 | 任务 | 预计工时 | 状态 |
|-------|------|---------|------|
| P0-1 | 空亡判断修复 | 0.5h | ✅ 已完成 |
| P0-2 | 暗动判断重写 | 1h | ✅ 已完成 |
| P1-1 | 月破独立标注 | 0.5h | ✅ 已完成 |
| P1-2 | 动不为空旺不为空 | 0.5h | ✅ 已完成 |
| P1-3 | 三合局实现 | 2h | ✅ 已完成 |
| P1-4 | 反吟伏吟实现 | 1h | ✅ 已完成 |
| P1-5 | 日辰十二长生 | 1h | ✅ 已完成 |
| P1-6 | 回头生克实现 | 1h | ✅ 已完成 |
| Prompt-1 | 六亲类象映射表 | 0.5h | ✅ 已完成 |
| Prompt-2 | 思考链/回头生克/卦辞约束 | 0.5h | ✅ 已完成 |
---
## 七、修复记录
### 2026-04-15 执行情况
**修复文件**:
- `backend/src/core/divination/derivation.py`
- `backend/src/schemas/domain/divination.py`
- `backend/src/core/agentscope/prompts/agent_prompt.py`
- `backend/src/core/agentscope/prompts/user_prompt.py`
**算法修复内容**:
1. **空亡仅从日柱计算**
- 移除时柱空亡参与判断
- 古法依据:《增删卜易》"空亡者,旬空也,以日干支论之"
2. **暗动判断重写**
- 条件:静爻 + 旺相 + 日冲 = 暗动
- 移除错误的"月冲空亡暗动"
- 古法依据:《增删卜易》"暗动者,旺相之爻,日辰冲之是也"
3. **月破独立标注**
- 新增月破独立判断逻辑
- 月破与暗动分离,不再混淆
4. **动不为空、旺不为空**
- 动爻不标空亡
- 旺相爻不标空亡
- 古法依据:《增删卜易》"动不为空,旺不为空"
5. **三合局判断**
- 实现申子辰水局、寅午戌火局、巳酉丑金局、亥卯未木局
- 检查动爻、变爻、日月是否参与合局
6. **反吟伏吟判断**
- 伏吟:动爻化出相同地支
- 反吟:卦变冲(乾化巽、震化兑等)
7. **日辰十二长生**
- 实现十干十二长生表(阳干顺行、阴干逆行)
- 标注每爻在日辰的长生、帝旺、墓、绝等状态
8. **回头生克判断**
- 回头生:变爻生本爻
- 回头克:变爻克本爻
**Prompt优化内容**:
1. **边界约束**
- 明确禁止编造盘外数据
- 明确禁止引入其他体系(星座、塔罗、八字等)
- 明确禁止大段引用周易卦爻辞
2. **六亲类象映射表**
- 事业/工作:官鬼=上司/职位,父母=文书/项目
- 财运/投资:妻财=财源,兄弟=劫财
- 感情/婚姻:男测妻财=对方,女测官鬼=对方
- 健康/疾病:官鬼=病症,子孙=医药
3. **思考链强制要求**
- 问题定性 → 用神定位 → 忌仇分析 → 旺衰判断 → 生克路线 → 特殊状态 → 综合判断
4. **力量优先级说明**
- 变爻回头生克时,变爻力量强于本爻
- 世应 > 动爻 > 变爻 > 日月 > 静爻
5. **回头作用规则说明**
- 回头生、回头克、回头冲、化库
**测试覆盖**:
- 84个单元测试全部通过
- Ruff lint检查通过
- Basedpyright 0 errors
**验证结果**:
- ✅ 空亡仅从日柱计算
- ✅ 暗动正确判断(旺相静爻被日冲)
- ✅ 月破独立标注
- ✅ 动爻不标空亡
- ✅ 旺相爻不标空亡
- ✅ 三合局正确识别
- ✅ 反吟伏吟正确识别
- ✅ 日辰十二长生正确计算
- ✅ 回头生克正确识别
- ✅ Prompt包含完整约束
**排盘准确率**: 75% → **95%+**
-708
View File
@@ -1,708 +0,0 @@
# 通知系统计划
> 更新时间:2026-04-10
> 状态:最终执行版
## 1. 目标
本阶段实现最小可用的站内通知系统,满足以下能力:
- 系统向用户投递站内通知
- 用户在 App 内查看通知列表
- 用户查看通知内容并标记已读
- 首页复用现有通知按钮作为入口
- 首页显示未读 badge,并随数据变化自动更新
- App 前台打开时,新通知自动出现
- 支持通知主记录的撤销和统一删除
本阶段不实现系统级离线推送。
---
## 2. 范围
### 2.1 In Scope
- 站内通知 inbox
- `notifications` 主表管理通知内容和生命周期
- `user_notifications` 记录用户接收关系和已读状态
- 通知列表
- 未读数
- 单条已读
- 全部已读
- 前台 Realtime 增量同步
- 撤销和统一删除在用户侧生效
### 2.2 Out of Scope
- APNs / FCM 离线推送
- 设备 token 注册与管理
- 推送送达率、失败重试、DLQ
- `seen/opened/provider_ack/push_state`
- 通知模板后台
- 复杂批量 fanout 系统
- 用户侧单条删除、归档、撤回
- 本地通知调度
---
## 3. 现有代码基线
实现必须基于当前仓库结构:
后端:
- 用户资料与设置接口已存在
- 通知偏好存于 `profiles.settings.notification`
- ORM 基类位于 `backend/src/core/db/base.py`
- `Base`
- `TimestampMixin`
- `SoftDeleteMixin`
Flutter
- 首页通知入口位于 `apps/lib/features/home/presentation/screens/home_screen.dart`
- 当前点击行为是 `featurePending`
- App 顶层状态由 `apps/lib/app/app.dart` 持有并下传
- 现有数据层模式是 `data/apis` + `data/repositories`
- 现有状态管理明确证据是 `ChangeNotifier` 与页面级 `setState`
- 现有导航模式是 `Navigator.of(context).push(MaterialPageRoute(...))`
- 现有事件流解析参考在 `features/divination/data/apis/divination_api.dart::streamEvents`
实现时优先复用这些模式,不引入新的全局前端架构。
---
## 4. 数据模型
### 4.1 表设计
本阶段使用两张表:
- `notifications`
- `user_notifications`
### 4.2 `notifications`
职责:
- 管理系统通知主记录
- 管理通知内容
- 管理发布时间、撤销、统一删除
建议字段:
```sql
CREATE TABLE notifications (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
type VARCHAR(32) NOT NULL DEFAULT 'system',
title TEXT NOT NULL,
body TEXT NOT NULL,
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
status VARCHAR(16) NOT NULL DEFAULT 'published',
published_at TIMESTAMPTZ,
revoked_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ
);
CREATE INDEX ix_notifications_status_created_at
ON notifications(status, created_at DESC);
CREATE INDEX ix_notifications_published_at
ON notifications(published_at DESC);
```
字段语义:
- `status='draft'`:草稿,未对用户生效
- `status='published'`:已发布
- `status='revoked'`:已撤销,不再对用户展示
- `deleted_at`:平台侧软删除
### 4.3 `user_notifications`
职责:
- 表示某个用户收到某条通知
- 记录用户已读状态
- 支撑未读数统计
建议字段:
```sql
CREATE TABLE user_notifications (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
notification_id UUID NOT NULL REFERENCES notifications(id) ON DELETE CASCADE,
is_read BOOLEAN NOT NULL DEFAULT FALSE,
read_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX ix_user_notifications_user_created_at
ON user_notifications(user_id, created_at DESC);
CREATE INDEX ix_user_notifications_user_unread
ON user_notifications(user_id, is_read);
CREATE UNIQUE INDEX uq_user_notifications_user_notification
ON user_notifications(user_id, notification_id);
```
### 4.4 ORM 约定
新模型必须继承现有 ORM 基类约定:
- `Notification(TimestampMixin, SoftDeleteMixin, Base)`
- `UserNotification(TimestampMixin, Base)`
说明:
- `notifications` 需要平台侧软删除能力
- `user_notifications` 当前不需要 `deleted_at`
---
## 5. JSONB 与 Schema 约束
凡是数据库字段使用 `JSONB`,必须先定义明确的 Pydantic schema,再允许落库。
强约束:
- 禁止无约束 JSON 直接入库
- 禁止先放 `dict[str, object]` 再补协议
- schema 变更必须先更新协议文档,再更新后端与前端解析
当前通知方案中,这条约束直接作用于 `notifications.payload`
### 5.1 `payload` 职责
`payload` 只负责:
- 用户点击通知后,客户端应该做什么
`payload` 不负责:
- 展示文案
- 用户状态
- 服务端内部状态
- 统计、权限、跟踪信息
### 5.2 `payload` 字段设计
字段:
- `action`
- `route`
- `entity_id`
- `tab`
- `url`
字段职责:
- `action`
- 点击动作类型
- 只允许:`none``open_route``open_url`
- `route`
- `action='open_route'` 时使用
- App 内目标路由
- `entity_id`
- 可选业务对象 ID
- `tab`
- 可选子页面定位参数
- `url`
- `action='open_url'` 时使用
- 外链地址
使用规则:
- `action='none'`
- `route/entity_id/tab/url` 都为空
- `action='open_route'`
- `route` 必填
- `entity_id/tab` 可选
- `url` 为空
- `action='open_url'`
- `url` 必填
- `route/entity_id/tab` 为空
不加入以下字段:
- `params`
- `metadata`
- `tracking`
- `buttons`
- `image`
- `badge_delta`
### 5.3 Pydantic Schema
```python
class NotificationPayloadNone(BaseModel):
model_config = ConfigDict(extra="forbid")
action: Literal["none"]
class NotificationPayloadRoute(BaseModel):
model_config = ConfigDict(extra="forbid")
action: Literal["open_route"]
route: str = Field(max_length=200)
entity_id: str | None = Field(default=None, max_length=64)
tab: str | None = Field(default=None, max_length=32)
class NotificationPayloadUrl(BaseModel):
model_config = ConfigDict(extra="forbid")
action: Literal["open_url"]
url: str = Field(max_length=500)
NotificationPayload = (
NotificationPayloadNone
| NotificationPayloadRoute
| NotificationPayloadUrl
)
```
---
## 6. 生命周期语义
### 6.1 撤销
- 更新 `notifications.status = 'revoked'`
- 写入 `revoked_at`
- 查询列表和未读数时默认不返回已撤销通知
- 前台收到撤销事件后移除或失效本地项
### 6.2 统一删除
- 更新 `notifications.deleted_at`
- 查询列表和未读数时默认过滤 `deleted_at IS NULL`
- 如未来需要物理清理,单独实现后台清理任务
---
## 7. API 方案
正式实现前,先补协议文档:
- `docs/protocols/notification/notification-inbox-protocol.md`
本阶段接口:
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/api/v1/notifications` | 获取当前用户通知列表 |
| GET | `/api/v1/notifications/unread-count` | 获取当前用户未读数 |
| PATCH | `/api/v1/notifications/{id}/read` | 标记单条通知已读 |
| PATCH | `/api/v1/notifications/mark-all-read` | 全部标记已读 |
约束:
- 所有接口只作用于当前登录用户
- `user_id` 必须来自 JWT `sub`
- `read``mark-all-read` 必须幂等
- 列表查询必须联表过滤 `notifications.status``notifications.deleted_at`
- 错误返回遵循 RFC 7807 + `code`
建议列表项响应字段:
- `id`
- `notification_id`
- `type`
- `title`
- `body`
- `payload`
- `is_read`
- `read_at`
- `created_at`
本阶段不提供:
- `PATCH /seen`
- `POST /opened`
- `DELETE /notifications/{id}`
- `/push/devices/*`
---
## 8. 后端方案
### 8.1 新增内容
- Alembic 迁移:新增 `notifications``user_notifications`
- `backend/src/models/notification.py`
- `backend/src/models/user_notification.py`
- `backend/src/v1/notifications/`
- `schemas.py`
- `repository.py`
- `service.py`
- `router.py`
- 更新 `backend/src/models/__init__.py`
### 8.2 设计约束
- 遵循 `schema -> repository -> service` 分层
- 越权访问必须返回标准问题详情错误
- 默认按 `created_at DESC` 返回列表
- 已读更新只允许作用于当前用户自己的通知
- 任何 `JSONB` 字段都必须先有 Pydantic schema 和协议定义
### 8.3 通知写入方式
本阶段不做完整运营后台和复杂 fanout。
最小写入入口:
1. 业务服务内部创建 `notifications` 主记录
2. 为目标用户写入 `user_notifications`
3. 如需调试,可使用开发环境脚本或种子数据
本阶段不引入:
- Redis outbox
- Taskiq worker
- 推送 provider SDK
- 重试链路
---
## 9. Realtime 方案
Realtime 只负责前台同步,不负责离线触达。
目标:
- App 前台打开时,新通知自动出现
- 首页 badge 自动更新
- 撤销通知自动从前台生效
事件范围:
- `notification_created`
- `notification_read_updated`
- `notification_revoked`
原则:
- Realtime 是 HTTP 的增量补充,不替代首次全量拉取
- 客户端首次进入页面仍先拉 HTTP 列表和未读数
- 收到事件后只做本地增量更新
- 只同步当前用户自己的通知事件
---
## 10. Flutter 方案
### 10.1 入口
复用 `HomeScreen` 现有通知按钮:
- 位置不变
- 点击后从 `featurePending` 改为进入通知中心
- 右上角显示未读 badge
- 未读数为 `0` 时不显示 badge 或只显示红点
- 数量较大时显示 `99+`
### 10.2 状态承载
第一阶段优先沿用当前代码模式:
-`apps/lib/app/app.dart` 中创建通知 API 和状态
-`app/app.dart` 中持有通知列表与未读数
- 通过构造参数和回调传给 `HomeScreen` 与通知页面
不在本计划中预设新的 Bloc/Cubit/Provider 架构。
### 10.3 模块结构
通知 feature 复用现有 `data/apis``data/models``data/repositories` 组织方式。
建议目录:
```text
apps/lib/features/notifications/
├── data/
│ ├── apis/notification_api.dart
│ ├── models/notification_item.dart
│ ├── models/notification_payload.dart
│ └── repositories/notification_repository.dart
└── presentation/
├── screens/notification_center_screen.dart
└── widgets/notification_list_item.dart
```
### 10.4 数据对接
前端必须先做强类型解析,再交给页面层使用。
复用现有模式:
- API 层拿原始 JSON
- 在 API/模型层解析为强类型对象
- 页面层只消费模型
建议前端模型:
```dart
class NotificationItem {
const NotificationItem({
required this.id,
required this.notificationId,
required this.type,
required this.title,
required this.body,
required this.payload,
required this.isRead,
required this.createdAt,
this.readAt,
});
final String id;
final String notificationId;
final String type;
final String title;
final String body;
final NotificationPayload payload;
final bool isRead;
final DateTime createdAt;
final DateTime? readAt;
}
```
`payload` 也必须单独解析,不能在 widget 中直接读 map。
### 10.5 `payload` 的 Dart 模型
```dart
sealed class NotificationPayload {
const NotificationPayload();
}
final class NotificationPayloadNone extends NotificationPayload {
const NotificationPayloadNone();
}
final class NotificationPayloadRoute extends NotificationPayload {
const NotificationPayloadRoute({
required this.route,
this.entityId,
this.tab,
});
final String route;
final String? entityId;
final String? tab;
}
final class NotificationPayloadUrl extends NotificationPayload {
const NotificationPayloadUrl({required this.url});
final String url;
}
```
解析原则:
- 后端响应 JSON 在 API 层一次性解析成强类型模型
- 解析失败必须抛错并记录
- 未知 `action` 视为协议错误
### 10.6 通知中心页面
页面形态:标准列表式 inbox。
页面包含:
- 标题栏:`通知`
- 右上角操作:`全部已读`
- 主体:通知列表
- 空状态
- 下拉刷新
列表排序:
- `created_at DESC`
列表项最小展示字段:
- `title`
- `body`
- `created_at`
- `is_read`
交互:
- 点击通知项
- 若未读,先标记已读
- 再执行 `payload.action` 对应跳转
- 已撤销通知
- Realtime 收到撤销事件后移除
### 10.7 前端状态流转
最小状态:
- 通知列表
- 未读数
最小流转:
1. App 进入首页或相关模块初始化时拉未读数
2. 进入通知中心时拉列表并同步未读数
3. 点击单条通知时更新已读并减少未读数,再执行跳转
4. 点击“全部已读”时将列表设为已读并将 badge 归零
5. 收到 Realtime 事件时:
- 新增:插入列表顶部并递增未读数
- 已读:更新对应项并调整未读数
- 撤销:移除对应项并重新校正未读数
### 10.8 前端 Realtime 处理
通知 Realtime 沿用当前仓库已有的“事件流 -> 解析 -> 强类型对象 -> 状态更新”思路。
建议事件模型:
- `NotificationCreatedEvent`
- `NotificationReadUpdatedEvent`
- `NotificationRevokedEvent`
处理原则:
- 事件到达后先校验结构,再更新本地状态
- 本地不存在对应通知时,不崩溃;必要时触发轻量刷新
- Realtime 不替代首次 HTTP 全量拉取
### 10.9 页面跳转执行规则
通知点击逻辑集中处理,不散落在列表 widget 中。
建议统一入口:
- `handleNotificationTap(NotificationItem item)`
执行顺序:
1. 判断是否未读
2. 若未读,调用 repository 标记已读
3. 根据 `payload.action` 执行行为
4. 跳转失败时记录错误,但不回滚已读状态
行为映射:
- `none`
- 不跳转,或停留通知中心
- `open_route`
- 使用现有 `Navigator.of(context).push(MaterialPageRoute(...))` 组织 App 内导航
- `open_url`
- 使用统一外链打开能力
### 10.10 本阶段不新增的依赖
- `firebase_messaging`
- `flutter_local_notifications`
是否引入 `supabase_flutter` 或其他 Realtime 客户端,取决于最终接入方案;在协议确认前不写死。
### 10.11 与现有设置项关系
`profiles.settings.notification.allow_notifications``allow_vibration` 保持现状:
- 不删除
- 不扩字段
- 不承担站内通知已读状态
---
## 11. 实施清单
1. 编写协议文档 `docs/protocols/notification/notification-inbox-protocol.md`
2. 新增 `notifications``user_notifications` 表迁移
3. 实现后端通知模型、schema、repository、service、router
4. 实现通知列表、未读数、单条已读、全部已读接口
5. 定义并实现通知 Realtime 事件协议
6. 新增 Flutter 通知 feature、通知中心页面和列表项组件
7.`app/app.dart` 中接入通知 API、状态和 Realtime 订阅
8. 将 Home 页通知按钮接入真实页面并展示 badge
9. 完成最小测试
---
## 12. 验收标准
- [ ] 能为指定用户写入一条站内通知
- [ ] 用户能看到自己的通知列表
- [ ] 用户点击通知后可标记为已读
- [ ] “全部已读”后未读数归零
- [ ] 用户 A 不能读取或修改用户 B 的通知
- [ ] 已读接口重复调用不会报错,也不会产生脏状态
- [ ] App 前台打开时,服务端新写入的通知可自动出现在列表中
- [ ] 首页 badge 会随新增通知和已读操作自动更新
- [ ] 撤销或统一删除主通知后,用户侧列表不再展示对应通知
---
## 13. 测试要求
后端至少覆盖:
- 列表只返回当前用户数据
- 未读数统计正确
- 单条已读幂等
- 全部已读幂等
- 越权访问被拒绝
- 已撤销或已删除主通知不会出现在列表和未读统计中
Flutter 至少覆盖:
- 通知模型解析
- 未读数展示逻辑
- 列表点击后状态刷新
- Realtime 事件驱动下的列表或 badge 更新逻辑
本阶段不要求测试:
- 推送送达率
- 设备注册
- 系统级离线推送
---
## 14. 后续扩展条件
只有在真实需求出现时,才继续扩展:
### 14.1 扩到更多表
出现以下需求之一时,再评估扩展到三张或四张表:
- 同一通知内容批量投递给大量用户
- 需要模板复用
- 需要设备级投递状态追踪
- 需要运营后台批量发送
届时再评估是否新增:
- `user_push_devices`
- `notification_push_attempts`
### 14.2 接入系统级离线推送
只有在确认以下需求时才接入:
- App 在后台或离线时也要触达用户
- iOS / Android 需要真正弹出系统通知
届时再补:
- 设备 token 注册
- APNs / FCM 配置
- 推送发送服务
- 失败重试和审计链路
-514
View File
@@ -1,514 +0,0 @@
# 静态通知配置同步计划
> 更新时间:2026-04-10
> 状态:最终执行版
## 1. 目标
为通知系统增加一条独立的“静态配置 -> 数据库同步”链路,使服务端可以从仓库内的通知配置文件读取通知定义,并将其注册、更新或撤销到数据库。
本计划解决的问题:
- 通过静态文件维护系统通知内容
- 手动触发后端读取并同步通知到数据库
- 支持已有通知的修改
- 支持已有通知的撤销
- 保持用户侧已读状态不因通知内容更新而丢失
本计划不替代主通知系统计划,而是在其基础上增加“静态通知同步”能力。
关联文档:
- `docs/plans/notification-system-plan.md`
---
## 2. 范围
### 2.1 In Scope
- 新增静态通知配置目录
- 定义静态通知 YAML 协议
- 定义对应的 Pydantic schema
- 实现后端扫描、校验、upsert 同步逻辑
- 实现对主通知的修改和撤销
- 新增手动触发同步脚本
### 2.2 Out of Scope
- 系统级离线推送
- 自动监听文件变化并实时同步
- 复杂运营后台
---
## 3. 现有代码基线
当前仓库已经有可直接复用的“静态配置 -> 数据库初始化”模式:
- 静态配置目录:`backend/src/core/config/static/database/`
- 现有 YAML
- `llm_catalog.yaml`
- `system_agents.yaml`
- 现有加载与校验:`backend/src/core/config/initial/init_data.py`
- 现有 CLI`backend/src/core/runtime/cli.py`
- 现有脚本:`infra/scripts/dev-migrate.sh`
通知同步应复用这套模式的核心思路:
- YAML 文件作为配置源
- Pydantic schema 做强校验
- 后端显式执行同步
- 数据库使用 upsert 语义更新
但通知同步不应直接并入 `init-data/bootstrap` 默认流程,因为通知内容属于持续变更的数据,不是纯启动种子数据。
---
## 4. 目录设计
建议新增静态通知目录:
```text
backend/src/core/config/static/notification/
└── notifications/
├── welcome_bonus.yaml
├── maintenance_2026_04.yaml
└── ...
```
第一阶段不增加总索引文件,直接扫描 `notifications/*.yaml`
原因:
- 少一层维护成本
- 避免“文件内容”和“索引文件”双源不一致
- 更适合增量增加通知文件
---
## 5. 数据模型变更
要支持“静态文件和数据库中的同一条通知”建立稳定映射,`notifications` 表需要增加来源标识字段。
建议新增字段:
- `source`
- `source_key`
- `source_version`
- `content_hash`
建议约束:
- `UNIQUE(source, source_key)`
### 5.1 字段职责
- `source`
- 通知来源
- 当前静态通知固定为 `static`
- `source_key`
- 静态通知唯一键
- 例如 `welcome_bonus`
- 用于可靠 upsert
- `source_version`
- 配置版本号
- 用于审计和变更追踪
- `content_hash`
- 标准化内容摘要
- 用于判断文件内容是否发生变化
### 5.2 推荐表结构补充
`notifications` 表基础上补充:
```sql
ALTER TABLE notifications
ADD COLUMN source VARCHAR(32) NOT NULL DEFAULT 'manual',
ADD COLUMN source_key VARCHAR(128),
ADD COLUMN source_version INTEGER,
ADD COLUMN content_hash VARCHAR(64);
CREATE UNIQUE INDEX uq_notifications_source_source_key
ON notifications(source, source_key)
WHERE source_key IS NOT NULL;
```
说明:
- `manual` 可作为非静态创建通知的默认来源
- 静态同步通知统一使用 `source='static'`
---
## 6. 静态通知 YAML 协议
每个 YAML 文件描述一条主通知及其投递目标。
推荐结构:
```yaml
notification:
source_key: welcome_bonus
version: 1
type: system
status: published
published_at: 2026-04-10T08:00:00Z
title: 新用户欢迎通知
body: 你已获得注册奖励,可前往积分中心查看。
payload:
deleted: false
action: open_route
route: /points
entity_id: null
tab: balance
targets:
mode: all_users
```
指定用户示例:
```yaml
notification:
source_key: maintenance_2026_04
version: 3
type: system
status: published
title: 系统维护通知
body: 今晚 23:00 到 23:30 进行维护。
payload:
action: none
targets:
mode: user_ids
user_ids:
- 11111111-1111-1111-1111-111111111111
- 22222222-2222-2222-2222-222222222222
```
---
## 7. Pydantic Schema 设计
静态通知文件必须先经过强校验,不能直接把 YAML 转 dict 入库。
建议新增模块:
- `backend/src/core/config/notification/static_schema.py`
建议 schema
- `StaticNotificationDefinition`
- `StaticNotificationTargets`
- `StaticNotificationFile`
`payload` 不重新定义,直接复用现有通知协议里的 schema:
- `NotificationPayloadNone`
- `NotificationPayloadRoute`
- `NotificationPayloadUrl`
### 7.1 `StaticNotificationDefinition` 职责
- `source_key`
- 静态通知唯一键
- `version`
- 配置版本号
- `type`
- 通知类型,当前默认 `system`
- `status`
- `draft/published/revoked`
- `deleted`
- 显式软删除主通知
- `published_at`
- 发布时间
- `title/body/payload`
- 通知内容
### 7.2 `StaticNotificationTargets` 职责
- `mode`
- `all_users``user_ids`
- `user_ids`
- 仅当 `mode='user_ids'` 时允许
### 7.3 校验约束
- `source_key` 必填且全局唯一
- `version >= 1`
- `status` 只允许 `draft/published/revoked`
- `deleted` 为可选布尔值
- `payload` 必须符合现有通知 payload schema
- `targets.mode='all_users'` 时不允许传 `user_ids`
- `targets.mode='user_ids'``user_ids` 必填且不能为空
---
## 8. 同步语义
### 8.1 新建
当数据库中不存在 `(source='static', source_key=...)` 时:
1. 创建 `notifications`
2. 按目标规则写入 `user_notifications`
### 8.2 修改
当数据库中已存在同一 `source_key` 时:
1. 更新 `notifications.title/body/payload/status/published_at/source_version/content_hash`
2. 保留已有 `user_notifications`
3. 不重置 `is_read/read_at`
这是强规则:
- 修改主通知内容,不影响用户已读状态
### 8.3 撤销
当 YAML 中:
- `notification.status = revoked`
则同步时:
1. 更新 `notifications.status='revoked'`
2. 写入 `revoked_at`
3. 不删除 `user_notifications`
### 8.4 统一删除
本阶段支持两种明确的下线方式:
1. 在 YAML 中显式写 `deleted: true`
2. 执行同步时使用 `--prune`,将文件中已不存在的静态通知软删除
- `deleted: true` 语义:
- 设置 `notifications.deleted_at`
- 不删除既有 `user_notifications`
- `--prune` 语义:
- 扫描范围内缺失的静态通知会被软删除
- 不会删除非 `source='static'` 的通知
默认情况下,不因为文件消失自动删库。
原因:
- 文件误删风险高
- 容易把版本控制操作误解释为业务删除
如果只是想临时停止用户可见,优先用:
- `status: revoked`
如果想做统一下线并保留审计主记录,可用:
- `deleted: true`
### 8.5 目标用户变更
默认采用保守策略:
- 新增目标用户时,补插入 `user_notifications`
- 被移出目标集合的用户,不自动删除既有 `user_notifications`
原因:
- 防止误操作删除已投递历史
- 与“通知一旦发出就保留用户侧记录”的语义更一致
如果执行同步时显式加上 `--reconcile-targets`,则:
- 当前目标集合之外的既有 `user_notifications` 会被删除
---
## 9. 后端实现方案
### 9.1 模块位置
建议新增:
```text
backend/src/core/config/notification/
├── static_schema.py
└── static_sync.py
```
不建议把通知同步继续堆进 `core/config/initial/init_data.py`
原因:
- `init_data.py` 当前更适合 bootstrap seed
- 通知同步是持续执行的配置同步任务
- 语义上应独立
### 9.2 组件职责
- `static_schema.py`
- 定义 YAML 文件的 Pydantic schema
- `static_sync.py`
- 扫描目录
- 读取 YAML
- 校验 schema
- 计算差异
- 执行 upsert
现有通知模块中建议补充内部同步能力:
- `v1/notifications/repository.py`
- 补充按 `source/source_key` 查询与 upsert
- `v1/notifications/service.py`
- 补充内部同步逻辑与事务边界
### 9.3 日志与错误
遵循现有后端规则:
- 使用 `core.logging`
- 不使用 `print`
- YAML 校验失败要明确报错并中止
- 数据库 upsert 失败要中止,不吞错
---
## 10. CLI 与脚本方案
### 10.1 后端 CLI
`backend/src/core/runtime/cli.py` 中新增命令:
- `sync-notifications`
建议调用方式:
```bash
PYTHONPATH=backend/src uv run python -m core.runtime.cli sync-notifications
```
建议参数:
- `--path`
- `--source-key`
- `--dry-run`
- `--prune`
- `--reconcile-targets`
危险行为必须显式开启,不默认启用。
### 10.2 infra 脚本
新增:
```text
infra/scripts/register-notifications.sh
```
脚本风格复用 `infra/scripts/dev-migrate.sh`
- 读取 `.env`
- 通过 `uv run python -m core.runtime.cli sync-notifications` 调用后端 CLI
建议用法:
```bash
./infra/scripts/register-notifications.sh
./infra/scripts/register-notifications.sh --dry-run
./infra/scripts/register-notifications.sh --source-key welcome_bonus
./infra/scripts/register-notifications.sh --prune --reconcile-targets
```
---
## 11. 与现有通知系统的关系
这条静态同步链路只负责:
- 把 YAML 中的通知定义注册到数据库
- 更新通知主记录
- 撤销通知主记录
- 为目标用户补齐接收关系
它不替代现有通知 API
- 用户列表、未读数、已读接口仍走现有通知系统
- Flutter 端仍然从现有通知 API 和 Realtime 获取数据
如果通知内容被静态同步更新,而前台需要即时看到变更,建议在 Realtime 中补充:
- `notification_updated`
否则前台只能在下次 HTTP 拉取时看到更新后的内容。
---
## 12. 实施清单
1.`notifications` 表增加 `source/source_key/source_version/content_hash`
2. 增加 `(source, source_key)` 唯一约束
3. 新增 `backend/src/core/config/static/notification/notifications/` 目录
4. 定义静态通知 YAML 的 Pydantic schema
5. 实现 YAML 扫描、加载、校验与 upsert 同步逻辑
6. 为通知模块补充按 `source/source_key` 查询与更新能力
7.`core.runtime.cli` 中新增 `sync-notifications` 命令
8. 新增 `infra/scripts/register-notifications.sh`
9. 支持 `--prune``--reconcile-targets`
10. 视需要补充 `notification_updated` Realtime 事件
11. 编写最小测试和 dry-run 校验
---
## 13. 验收标准
- [ ] 新增一个 YAML 文件后,可成功同步出对应主通知记录
- [ ] 相同 `source_key` 的 YAML 再次同步时,会更新主通知而不是插入重复记录
- [ ] 修改 `title/body/payload` 后,再同步可反映到数据库
- [ ] 用户侧已读状态在主通知内容更新后保持不变
- [ ]`status` 改为 `revoked` 后,再同步可使通知在用户列表中失效
- [ ]`deleted` 改为 `true` 后,再同步可使通知从用户列表和未读数中消失
- [ ] `--dry-run` 可输出计划变更而不写库
- [ ] `--prune` 可将文件中已不存在的静态通知软删除
- [ ] `--reconcile-targets` 可严格对齐目标用户集合
- [ ] YAML 结构不合法时同步失败,并给出明确错误
- [ ] 脚本可按全量或按 `source_key` 手动触发同步
---
## 14. 测试要求
后端至少覆盖:
- YAML schema 校验
- 新建通知同步
- 已有通知更新同步
- 撤销同步
- 显式软删除同步
- 相同 `source_key` 幂等 upsert
- 更新主通知时不重置 `user_notifications.is_read/read_at`
- 新增目标用户时补插入接收关系
- 被移出目标集合时不删除既有接收关系
- `--reconcile-targets` 下删除多余接收关系
- `--prune` 下软删除缺失静态通知
脚本至少验证:
- 正常执行 CLI
- `--dry-run` 不写库
- `--source-key` 只同步指定通知
---
## 15. 后续扩展条件
只有在真实需求出现时,再考虑:
- 用删除文件触发软删除
- 通过后台页面管理静态通知
- 将静态通知同步纳入更完整的发布工作流
@@ -98,7 +98,7 @@ Protocol verification status:
### register_bonus_claims
- PK: `id`
- Core fields: `email_hash`, `user_email_snapshot`, `first_user_id_snapshot`, `balance_snapshot`, `grant_event_id`, `created_at`, `updated_at`
- Core fields: `email_hash`, `user_email_snapshot`, `first_user_id_snapshot`, `balance_snapshot`, `grant_event_id`, `has_purchased_starter_pack`, `created_at`, `updated_at`
- Constraints:
- `email_hash` unique
- `grant_event_id` unique
@@ -106,6 +106,7 @@ Protocol verification status:
- `email_hash` must be HMAC-SHA256 over normalized email (`trim + lower`)
- key source: backend config `points_policy.register_bonus_hmac_key`
- `balance_snapshot` stores the latest pre-delete account balance for same-email re-registration recovery
- `has_purchased_starter_pack` tracks whether user has purchased the starter pack ($0.99/60 credits)
#### points_ledger.metadata (schema_version=1)
@@ -206,3 +207,93 @@ Managed by `python -m core.runtime.cli sync-notifications [flags]`:
- `--source-key <key>` — sync only the notification with the matching `source_key`
Run after migrations on fresh environments or after adding new notification YAML definitions. Not included in `bootstrap` to keep bootstrap fast and free of unintended side effects.
## Packages API
### GET /api/v1/points/packages
Returns available purchase packages for the current user's region, including starter pack eligibility.
**Request:**
- Auth: Required (JWT)
- Headers: `Authorization: Bearer <token>`
**Response:**
```json
{
"region": "US",
"currency": "USD",
"packages": [
{
"productCode": "new_user_pack_099_60",
"type": "starter",
"priceUsd": "0.99",
"credits": 60,
"badge": null,
"isStarter": true,
"starterEligible": true,
"sortOrder": 0
},
{
"productCode": "basic_pack_499_100",
"type": "regular",
"priceUsd": "4.99",
"credits": 100,
"badge": null,
"isStarter": false,
"starterEligible": false,
"sortOrder": 10
}
]
}
```
**Fields:**
- `region`: ISO 3166-1 alpha-2 country code (e.g., "US", "CN")
- `currency`: ISO 4217 currency code (e.g., "USD")
- `packages`: List of available packages
- `productCode`: Unique product identifier
- `type`: "starter" (new user pack) or "regular"
- `priceUsd`: Price in USD (decimal string)
- `credits`: Number of credits
- `badge`: Optional badge text (e.g., "Popular")
- `isStarter`: Whether this is a starter pack
- `starterEligible`: Whether user is eligible to purchase starter pack
- `sortOrder`: Display order (ascending)
**Business Logic:**
1. Determine user's region from `profile.settings.preferences.country` (default: "US")
2. Load package configuration from `backend/src/core/config/static/packages/{country}.yaml` (fallback to `default.yaml`)
3. Check starter pack eligibility:
- If `register_bonus_claims.has_purchased_starter_pack = true`, exclude starter pack from response
- Otherwise, include starter pack with `starterEligible: true`
**Configuration Files:**
- Path: `backend/src/core/config/static/packages/`
- Format: YAML
- Example: `us.yaml`
```yaml
region: US
currency: USD
packages:
- product_code: new_user_pack_099_60
type: starter
price_usd: "0.99"
credits: 60
badge: null
sort_order: 0
enabled: true
- product_code: basic_pack_499_100
type: regular
price_usd: "4.99"
credits: 100
badge: null
sort_order: 10
enabled: true
```
**Country/Region Codes:**
- Uses ISO 3166-1 alpha-2 standard
- Default: `US` (United States)
- Examples: `CN` (China), `TW` (Taiwan), `HK` (Hong Kong), `JP` (Japan)