# 架构（服务端模块 + 调用链）

## 代码模块布局

```
src/xiangqin/
├── __init__.py         # 版本号
├── app.py              # FastAPI entrypoint + 所有 endpoint
├── auth.py             # 注册 / 验证码 / session（短信 provider 可插拔）
├── cli.py              # xq CLI 主入口（Click）
├── client_session.py   # 客户端本机 session（~/.xiangqin/session.json）
├── credentials.py      # 读 .vault/secrets.json
├── db.py               # sqlite schema + 连接 + hash 工具
├── dsl.py              # WHERE DSL 解析器（白名单）
├── mail.py             # 虚拟信封 / 投递 / 通知 / 举报 / 拉黑
├── profile.py          # profile CRUD + 字段校验
├── query.py            # SQL 构建 + 排序（updated_at DESC）
└── sms/                # SMS provider (mock / aliyun)
    ├── base.py
    ├── mock.py
    └── aliyun.py
```

注：2026-04-23 砍了 `exposure.py` + `payment/`（曝光系统整体下线，M4 信封替代）。

## 请求路径（典型：发私信）

```
xq dm send 01JG... "hi"
    ↓ [Click CLI + httpx]
POST /dm/send { to_user_id, body }
    ↓ [app.py:dm_send]
mail_svc.send(from, to, body)
    ↓ [mail.py:send]
    ├── 查 users 表验 to 存在（不存在抛 recipient_not_found）
    ├── 查 blocks 表（拉黑 → delivery_status=blocked_by_recipient）
    ├── 若 delivered → _consume_envelope（扣免费 or 付费）
    ├── INSERT messages
    └── _maybe_notify（事务外）
        ↓ 查 profiles.agent_gateway_url / hooks_token / notify_on_new_mail
        ↓ 若开启且不抑制 → httpx.post(gateway/hooks/agent)
        └── UPDATE messages notify_status / notify_error
    ↓
返回 MessageRow → JSON 给 CLI
```

## 关键设计决策

### 1. 为什么通知在事务外

`_maybe_notify(msg)` 在 `with get_conn()` 块之外调用。理由：

- 推 hook 是网络 IO，可能慢（10s 超时）
- 不能让发信事务被网络等待
- 推送失败 / 成功不回滚消息落库（"信已到邮箱" 是真相，通知只是提醒的尝试）

代价：如果推送状态写 DB 失败，notify_status 可能落空。但这是容忍的——读时可以为 NULL。

### 2. 为什么信件 ≥ 2 未读才 suppress

`_should_suppress_dup` 里：

```python
if unread["n"] < 2:
    return False  # 不抑制
```

因为查询时当前这封信已经 INSERT，所以 `unread.n >= 1`。`>= 2` 才表示"除了这封外还有未读"，说明上次通知没看，再发等于打扰。

### 3. 为什么信封扣费在事务内

发信路径里 `_consume_envelope` + `INSERT messages` **必须原子**——否则可能扣了信封但消息没落库（用户钱没了但信没发）。因此扣费在事务内，推通知在事务外。

### 4. 为什么不把 messages.body 加密

短期 30 天 TTL + 举报取证需要 + 用户意图就是换联系方式不需要端到端加密 → 明文存。仅操作系统级文件权限 + sqlite 文件权限保护。

### 5. 为什么 agent_hooks_token 存明文

- 本身是低敏感凭证（泄露只影响被推送垃圾，不能碰用户钱）
- 存 hash 的话无法调 /hooks/agent（要原文头）
- 对称加密需要 KMS，MVP 不做

Profile 返回 CLI 时 mask（`_mask_token` 前4后4），防 stdout 泄漏。

### 6. 为什么不做受众定向

M4 ADR 决定先做信封 + 触达，**不**做发信方指定"只让 X 条件的人收到"。理由：

- 信封已经是付费筛选（不乱发）
- 受众定向给了"我只想骚扰 X 类型"的工具——短期风险 > 收益
- 等真付费数据起来再评估

## 依赖

### 服务端运行时

- Python 3.12+
- sqlite（内置）
- FastAPI + uvicorn
- httpx（webhook 推送 + 支付宝调用）
- cryptography（支付宝 RSA 验签）
- ulid-py

### 外部凭证（走 vault）

- aliyun AK/SK（OSS 备份 + DNS）
- aliyun-power-app AK/SK（短信）
- （0.1 期信封只 mock 支付，真付走支付宝待 v0.2 重新接入）

详见 `vault.json` 声明式引用。

## 性能考量（M4 落地后的假设）

当前 schema 应对到：

- **用户量**：≤ 10,000（sqlite 单机够，pure 读+写事务不超 100 TPS）
- **私信量**：≤ 10,000 / 日（30 天留存 → 30 万行，合理）
- **Index 策略**：`idx_messages_to_unread` 是热路径（inbox 查询用），用 partial index 只索引未删除 + delivered 的消息

超出此规模：迁 PostgreSQL + 读写分离 + 消息表按月分区。v0.2+ 再评估。
