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