# Tolan 技术实现文档（Technical Spec）

> 配套 [产品文档](TOLAN_PRD.md) 的工程落地文档。PRD 回答 **做什么 / 为什么**，本文回答 **具体怎么实现**：
> 接口契约、数据库建表、状态机、核心算法伪代码、客户端组件拆解、边界与错误处理。
>
> ⚠️ 技术栈为合理假设（无官方资料），落地时按团队实际替换；架构与契约设计与具体栈解耦。

---

## 目录

1. [系统架构](#1-系统架构)
2. [通用工程约定](#2-通用工程约定)
3. [数据库 Schema](#3-数据库-schema)
4. [客户端架构](#4-客户端架构)
5. [核心流程实现](#5-核心流程实现)
6. [模块实现：Notebook](#6-模块实现notebook)
7. [模块实现：Intentions](#7-模块实现intentions)
8. [模块实现：Friends](#8-模块实现friends)
9. [模块实现：Insights](#9-模块实现insights)
10. [模块实现：Check-Ins](#10-模块实现check-ins)
11. [模块实现：Shop](#11-模块实现shop)
12. [模块实现：Settings](#12-模块实现settings)
13. [异步任务与调度](#13-异步任务与调度)
14. [内容安全实现](#14-内容安全实现)
15. [可观测性与发布](#15-可观测性与发布)

---

## 1. 系统架构

### 1.1 技术栈（假设）

| 层 | 选型 | 说明 |
|---|---|---|
| iOS 客户端 | Swift 5.9 + SwiftUI + Combine | 单向数据流；3D 角色用 SceneKit/RealityKit |
| API 网关 | REST + WebSocket | HTTP/2，TLS 1.3 |
| 应用服务 | 无状态服务（容器化），水平扩展 | 按域拆分见下 |
| 关系数据库 | PostgreSQL 15 | 主数据 |
| 向量库 | pgvector / 专用向量库 | 长期记忆检索 |
| 缓存 / 限流 | Redis 7 | 配额计数、会话态、分布式锁 |
| 对象存储 | S3 兼容 | 插画、语音、导出包 |
| 消息队列 | Kafka / SQS | 异步任务解耦 |
| 任务调度 | 延迟队列 + Cron | check-in 触达、日记生成 |
| LLM | 托管大模型 API（流式） | 对话、抽取、生成 |
| ASR / TTS | 流式语音识别 / 合成 | 语音对话 |
| 图像生成 | 文生图模型 | 日记插画、里程碑速写 |
| 电话 / 短信 | Twilio（或同类） | Check-In 触达 |
| 推送 | APNs | 通知 |
| 支付 | StoreKit 2 | 订阅 + 消耗型 IAP |
| 分析 | 事件管道（如 Kafka → 数仓） | 埋点 |

### 1.2 服务拆分

```
┌────────────┐   REST/WS    ┌──────────────────────────────────────┐
│ iOS Client │◄────────────►│            API Gateway                │
└────────────┘              │  (鉴权 / 限流 / 路由 / WS 维持)         │
                            └───────────────┬──────────────────────┘
        ┌───────────────────────────────────┼────────────────────────────┐
        ▼                ▼                  ▼                ▼            ▼
  ┌───────────┐   ┌────────────┐   ┌──────────────┐  ┌───────────┐ ┌──────────┐
  │ chat-svc  │   │ content-svc│   │ growth-svc   │  │ commerce- │ │ user-svc │
  │ 对话/配额  │   │ 日记/洞察/  │   │ 邀请/check-in│  │ svc 订阅/  │ │ 账户/设置 │
  │ /记忆     │   │ 意图       │   │              │  │ 商店/T币   │ │          │
  └─────┬─────┘   └─────┬──────┘   └──────┬───────┘  └─────┬─────┘ └────┬─────┘
        │               │                 │                │            │
        └───────────────┴────────┬────────┴────────────────┴────────────┘
                                  ▼
                  ┌──────────────────────────────┐
                  │ PostgreSQL · Redis · 向量库     │
                  │ Kafka · S3 · Scheduler         │
                  └──────────────────────────────┘
        异步消费：worker-pool（记忆抽取 / 洞察抽取 / 日记生成 / check-in 触达）
```

### 1.3 关键非功能约束映射

| 来自 PRD | 技术手段 |
|---|---|
| NFR-PERF.2 语音首字 ≤ 2s | WS 长连 + LLM 流式 + 首 token 即返 |
| NFR-SEC.1 服务端为准 | 配额/T币/订阅状态只信服务端，客户端只读 |
| NFR-SEC.3 扣费一致性 | 单事务 + 幂等键 |
| NFR-PRIV.1 敏感数据加密 | 传输 TLS；存储列级加密（对话/日记/洞察） |

---

## 2. 通用工程约定

### 2.1 REST 约定

- Base URL：`https://api.tolan.ai/v1`
- 鉴权：`Authorization: Bearer <access_token>`（JWT，access 30min / refresh 30d）。
- 内容类型：`application/json; charset=utf-8`。
- 时间：ISO-8601 UTC（`2026-05-19T08:30:00Z`）。
- 幂等：写接口接受 `Idempotency-Key` 头（UUID），服务端 24h 内去重。

#### 统一响应包络

```json
// 成功
{ "ok": true, "data": { ... }, "trace_id": "req_a1b2c3" }

// 失败
{ "ok": false, "error": { "code": "QUOTA_EXHAUSTED",
  "message": "今天的聊天额度用完啦", "detail": {} }, "trace_id": "req_a1b2c3" }
```

#### 错误码表

| HTTP | code | 含义 | 客户端处理 |
|---|---|---|---|
| 400 | `INVALID_ARGUMENT` | 参数错误 | 提示并阻断 |
| 401 | `UNAUTHENTICATED` | token 失效 | 静默 refresh，失败则登出 |
| 403 | `FORBIDDEN` | 无权限 | 提示 |
| 403 | `QUOTA_EXHAUSTED` | 聊天配额耗尽 | 切换变现引导 UI |
| 402 | `INSUFFICIENT_TOKENS` | T 币不足 | 置灰 + 差额提示 |
| 404 | `NOT_FOUND` | 资源不存在 | 空状态 |
| 409 | `CONFLICT` | 幂等冲突 / 状态冲突 | 按业务回退 |
| 409 | `ALREADY_PURCHASED` | 重复购买 | 直接进装备态 |
| 429 | `RATE_LIMITED` | 频控 | 指数退避重试 |
| 500 | `INTERNAL` | 服务端错误 | 角色化报错 + 重试 |
| 503 | `LLM_UNAVAILABLE` | 模型不可用 | 角色化报错 + 重试 |

#### 分页

游标分页：请求 `?cursor=<opaque>&limit=20`，响应 `data.items[] + data.next_cursor`（null 表示到底）。

### 2.2 WebSocket 约定（聊天）

- 端点：`wss://api.tolan.ai/v1/chat/stream?token=<access_token>`
- 帧格式：JSON，统一 `{ "type": "...", "payload": {...}, "seq": <int> }`。
- 心跳：客户端每 20s 发 `ping`，服务端 `pong`；60s 无活动服务端断开。
- 断线重连：指数退避（1s,2s,4s,…,≤30s），重连后用 `resume` 帧带最后 `seq` 续传。

### 2.3 客户端缓存层级

| 数据 | 缓存策略 |
|---|---|
| 日记 / 洞察列表 | SQLite 本地镜像，进入页面先读缓存再后台刷新（stale-while-revalidate） |
| 商品目录 | 带 ETag，TTL 1h |
| 角色装扮 / 库存 | 本地持久 + 服务端为权威 |
| 配额 / T 币余额 | 不缓存，每次进入相关页拉取 |

---

## 3. 数据库 Schema

仅列核心表，PostgreSQL DDL。敏感文本列（对话/日记/洞察正文）应用列级加密，下方以 `ENCRYPTED` 注释标记。

```sql
-- ===== 用户与设置 =====
CREATE TABLE users (
  user_id        UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  email          CITEXT UNIQUE,
  auth_provider  TEXT NOT NULL,              -- apple/google/email
  tolan_name     TEXT NOT NULL DEFAULT 'Tolan',
  tolan_voice_id TEXT,
  timezone       TEXT NOT NULL DEFAULT 'UTC',
  created_at     TIMESTAMPTZ NOT NULL DEFAULT now(),
  deleted_at     TIMESTAMPTZ                 -- 软删，合规期后物理清除
);

CREATE TABLE user_settings (
  user_id          UUID PRIMARY KEY REFERENCES users,
  default_input    TEXT NOT NULL DEFAULT 'voice',   -- voice/text
  theme            TEXT NOT NULL DEFAULT 'system',
  language         TEXT NOT NULL DEFAULT 'zh-CN',
  push_enabled     BOOLEAN NOT NULL DEFAULT true,
  quiet_hours_start SMALLINT,                        -- 0-1439 分钟，本地时
  quiet_hours_end   SMALLINT,
  phone_e164       TEXT,                             -- ENCRYPTED
  phone_verified   BOOLEAN NOT NULL DEFAULT false,
  updated_at       TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- ===== 订阅与钱包 =====
CREATE TABLE subscriptions (
  user_id        UUID PRIMARY KEY REFERENCES users,
  plan           TEXT NOT NULL DEFAULT 'free',   -- free/lite/plus
  source         TEXT,                            -- appstore/trial/referral
  expires_at     TIMESTAMPTZ,
  auto_renew     BOOLEAN NOT NULL DEFAULT false,
  updated_at     TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE wallets (
  user_id        UUID PRIMARY KEY REFERENCES users,
  token_balance  INTEGER NOT NULL DEFAULT 0 CHECK (token_balance >= 0),
  relay_balance  INTEGER NOT NULL DEFAULT 0 CHECK (relay_balance >= 0),
  updated_at     TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE token_transactions (
  txn_id         UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id        UUID NOT NULL REFERENCES users,
  type           TEXT NOT NULL,                  -- earn/spend
  source         TEXT NOT NULL,                  -- intention/invite/checkin/purchase/shop
  amount         INTEGER NOT NULL,               -- 正数
  balance_after  INTEGER NOT NULL,
  ref_id         UUID,                           -- 关联业务对象
  idempotency_key TEXT UNIQUE,
  created_at     TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX ix_txn_user_time ON token_transactions(user_id, created_at DESC);

-- ===== 对话与配额 =====
CREATE TABLE conversations (
  conv_id        UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id        UUID NOT NULL REFERENCES users,
  started_at     TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE messages (
  msg_id         UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  conv_id        UUID NOT NULL REFERENCES conversations,
  user_id        UUID NOT NULL REFERENCES users,
  role           TEXT NOT NULL,                  -- user/assistant
  content        TEXT NOT NULL,                  -- ENCRYPTED
  input_mode     TEXT,                           -- voice/text
  created_at     TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX ix_msg_user_time ON messages(user_id, created_at DESC);

CREATE TABLE daily_usage (
  user_id        UUID NOT NULL REFERENCES users,
  usage_date     DATE NOT NULL,                  -- 用户本地日期
  used_count     INTEGER NOT NULL DEFAULT 0,
  PRIMARY KEY (user_id, usage_date)
);

-- ===== 长期记忆（向量）=====
CREATE TABLE memories (
  memory_id      UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id        UUID NOT NULL REFERENCES users,
  text           TEXT NOT NULL,                  -- ENCRYPTED
  embedding      VECTOR(1536),
  salience       REAL NOT NULL DEFAULT 0.5,      -- 重要度，影响检索/淘汰
  source_msg_id  UUID,
  created_at     TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX ix_mem_user ON memories(user_id);
-- 向量索引（IVFFlat 示例）
CREATE INDEX ix_mem_embedding ON memories USING ivfflat (embedding vector_cosine_ops);

-- ===== 内容：日记 / 意图 / 洞察 =====
CREATE TABLE notebook_entries (
  entry_id        UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id         UUID NOT NULL REFERENCES users,
  entry_date      DATE NOT NULL,
  author_name     TEXT NOT NULL,
  illustration_url TEXT,
  body            TEXT NOT NULL,                 -- ENCRYPTED
  affirmation     TEXT,
  created_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
  UNIQUE (user_id, entry_date)
);

CREATE TABLE intention_paths (
  path_id        UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id        UUID NOT NULL REFERENCES users,
  statement      TEXT NOT NULL,
  focus_on       TEXT,
  avoid          TEXT,
  progress_total INTEGER NOT NULL DEFAULT 15,
  progress_done  INTEGER NOT NULL DEFAULT 0,
  is_active      BOOLEAN NOT NULL DEFAULT true
);

CREATE TABLE milestones (
  milestone_id   UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  path_id        UUID NOT NULL REFERENCES intention_paths,
  idx            INTEGER NOT NULL,
  status         TEXT NOT NULL DEFAULT 'locked',  -- locked/active/done
  date_range     TEXT,
  sketch_url     TEXT,
  sketch_caption TEXT,
  reward_tokens  INTEGER NOT NULL DEFAULT 0,
  required_count INTEGER NOT NULL DEFAULT 3,
  UNIQUE (path_id, idx)
);

CREATE TABLE intentions (
  intention_id   UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  milestone_id   UUID NOT NULL REFERENCES milestones,
  user_id        UUID NOT NULL REFERENCES users,
  title          TEXT NOT NULL,
  intention_date DATE NOT NULL,
  status         TEXT NOT NULL DEFAULT 'open'     -- open/done
);

CREATE TABLE insights (
  insight_id     UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id        UUID NOT NULL REFERENCES users,
  dimension      TEXT NOT NULL,                   -- drive/wellbeing/creativity/curiosity/relationships
  text           TEXT NOT NULL,                   -- ENCRYPTED
  source_ref     UUID,                            -- 关联 message/conversation
  created_at     TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX ix_insight_user_dim ON insights(user_id, dimension, created_at DESC);

CREATE TABLE readings (
  reading_id     UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id        UUID NOT NULL REFERENCES users,
  idx            INTEGER NOT NULL,
  content        TEXT NOT NULL,                   -- ENCRYPTED
  unlocked_at    TIMESTAMPTZ NOT NULL DEFAULT now(),
  UNIQUE (user_id, idx)
);

-- ===== Check-In =====
CREATE TABLE check_ins (
  checkin_id     UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id        UUID NOT NULL REFERENCES users,
  raw_text       TEXT NOT NULL,
  schedule_kind  TEXT NOT NULL,                   -- once/recurring
  cron_expr      TEXT,                            -- recurring 用
  fire_at        TIMESTAMPTZ,                     -- once 用
  channel        TEXT NOT NULL,                   -- call/sms/push
  status         TEXT NOT NULL DEFAULT 'active',  -- active/paused/deleted
  next_fire_at   TIMESTAMPTZ,
  created_at     TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX ix_checkin_due ON check_ins(next_fire_at) WHERE status = 'active';

-- ===== 商店 =====
CREATE TABLE shop_items (
  item_id        UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  category       TEXT NOT NULL,                   -- clothes/decor
  sub_category   TEXT NOT NULL,
  name           TEXT NOT NULL,
  preview_url    TEXT NOT NULL,
  asset_url      TEXT NOT NULL,
  price_tokens   INTEGER NOT NULL,
  is_limited     BOOLEAN NOT NULL DEFAULT false,
  available_from TIMESTAMPTZ,
  available_to   TIMESTAMPTZ
);

CREATE TABLE user_inventory (
  user_id        UUID NOT NULL REFERENCES users,
  item_id        UUID NOT NULL REFERENCES shop_items,
  acquired_at    TIMESTAMPTZ NOT NULL DEFAULT now(),
  equipped       BOOLEAN NOT NULL DEFAULT false,
  PRIMARY KEY (user_id, item_id)
);

-- ===== 邀请 =====
CREATE TABLE invites (
  invite_id      UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  inviter_id     UUID NOT NULL REFERENCES users,
  invitee_id     UUID REFERENCES users,
  link_code      TEXT UNIQUE NOT NULL,
  status         TEXT NOT NULL DEFAULT 'pending', -- pending/registered/rewarded/rejected
  reward_tokens  INTEGER NOT NULL DEFAULT 1000,
  created_at     TIMESTAMPTZ NOT NULL DEFAULT now()
);
```

---

## 4. 客户端架构

### 4.1 分层

```
View (SwiftUI)
   │  绑定
ViewModel (@MainActor, ObservableObject)   ── 持有 UI 状态、调用 UseCase
   │
UseCase / Service                          ── 业务逻辑，编排 Repository
   │
Repository                                 ── 数据获取，决定走缓存还是网络
   │
 ┌────────────┬─────────────┐
 APIClient   WSClient     LocalStore(SQLite/Keychain)
```

### 4.2 通用 UI 状态枚举

所有内容页 ViewModel 暴露统一状态（对应 PRD §3.4 五态）：

```swift
enum ViewState<T> {
    case loading            // 仅当 >300ms 才渲染骨架
    case empty(EmptyConfig) // 角色化空状态：插画 + 文案 + CTA
    case loaded(T)
    case error(AppError)    // 角色化文案 + 重试闭包
    case offline(cached: T?) // 顶部提示条 + 可选缓存内容
}
```

### 4.3 通用组件实现要点

| 组件 | 实现要点 |
|---|---|
| 侧边栏 Drawer | `offset` 动画 + 拖拽手势；当前路由高亮由全局 `Router.current` 驱动 |
| T 币计数器 | 订阅 `WalletStore.balance`；变动时 `withAnimation` 数字滚动 |
| 配额条 | 订阅 `QuotaStore`；`remaining ≤ 20%` 切珊瑚色；`= 0` 发 `quotaExhausted` 通知 |
| 五态容器 | 泛型 `StateContainer<T>`，包裹任意内容，统一渲染 loading/empty/error |
| 锁定遮罩 | `LockedOverlay`：内容 `.blur(8)` + 锁图标 + 升级 CTA |

### 4.4 路由

集中式 `Router`，枚举驱动：

```swift
enum Route: Hashable {
    case home, notebook, intentions, friends, insights
    case checkIns, shop, planet, settings
    case paywall(trigger: PaywallTrigger)
}
```

---

## 5. 核心流程实现

### 5.1 聊天流式时序

```
Client                  Gateway/chat-svc            LLM/ASR/TTS        worker
  │ 采集音频帧 ─audio──────►│                            │                │
  │                        │ 流式 ASR ─────────────────►│                │
  │ ◄──partial transcript──│◄───partial─────────────────│                │
  │                        │ ── 用户停顿/结束 ──         │                │
  │                        │ 1. 配额校验(Redis)          │                │
  │                        │ 2. 危机检测(input)          │                │
  │                        │ 3. 组装上下文(记忆检索)      │                │
  │                        │ 4. LLM 流式生成 ──────────►│                │
  │ ◄──token 流(assistant)─│◄───token stream────────────│                │
  │ ◄──audio 流(TTS)───────│ 5. 危机检测(output)         │                │
  │                        │ 6. 落库 messages            │                │
  │                        │ 7. 配额 used_count++        │                │
  │                        │ 8. 发 Kafka: turn_done ────┼───────────────►│
  │                        │                            │  记忆抽取/洞察抽取
```

### 5.2 WebSocket 帧定义

```jsonc
// 客户端 → 服务端
{ "type": "audio_chunk", "payload": { "data": "<base64 pcm>" }, "seq": 12 }
{ "type": "audio_end",   "payload": {}, "seq": 13 }
{ "type": "text_input",  "payload": { "text": "今天好累" }, "seq": 14 }
{ "type": "ping" }

// 服务端 → 客户端
{ "type": "transcript_partial", "payload": { "text": "今天好" } }
{ "type": "transcript_final",   "payload": { "text": "今天好累", "msg_id": "..." } }
{ "type": "assistant_token",    "payload": { "text": "我" } }          // 流式
{ "type": "assistant_audio",    "payload": { "data": "<base64>" } }
{ "type": "assistant_done",     "payload": { "msg_id": "...", "quota_remaining": 0.74 } }
{ "type": "crisis_detected",    "payload": { "resources": [ ... ] } }  // 见 §14
{ "type": "error", "payload": { "code": "QUOTA_EXHAUSTED" } }
```

### 5.3 配额扣减算法

定义：**1 条 user 消息 = 1 配额单位**（见 PRD §D 待确认；本文按此实现）。

```
function consumeQuota(user_id):
    plan      = getPlan(user_id)                 # free/lite/plus
    limit     = QUOTA_LIMIT[plan]                # 例: free=20, lite=200, plus=∞
    today     = localDate(user_id)               # 按 users.timezone 计算
    key       = "quota:" + user_id + ":" + today

    # Redis 原子自增，首次设置当日 23:59 过期
    used = redis.INCR(key)
    if used == 1: redis.EXPIREAT(key, endOfLocalDay(user_id))

    if used <= limit:
        return { ok: true, remaining: (limit - used) / limit }

    # 超出每日配额 → 尝试消耗 Relay 包
    if atomicDecrIfPositive("relay:" + user_id) > 0:
        return { ok: true, remaining: 0, via: "relay" }

    redis.DECR(key)                               # 回滚未生效的自增
    return { ok: false, code: "QUOTA_EXHAUSTED" }
```

- 写库 `daily_usage` 由异步 job 周期对账（Redis 为热路径，DB 为权威备份）。
- `endOfLocalDay` 用 `users.timezone` 计算，保证 PRD `FR-GC.8` 本地 00:00 重置。

### 5.4 上下文组装

```
function buildContext(user_id, userText):
    persona   = loadPersonaProfile(user_id)        # 角色名/音色/性格摘要
    recent    = lastNMessages(user_id, n=12)       # 近 12 轮原文
    memHits   = vectorSearch(memories, embed(userText), user_id, topK=6)
    intention = activeIntentionToday(user_id)       # 可能为空

    systemPrompt = TEMPLATE(
        tolan_name = persona.name,
        traits     = persona.traits,
        memories   = memHits.map(.text),
        intention  = intention?.title,
        safety     = SAFETY_RULES)                  # 见 §14

    return [systemPrompt] + recent + [{role:user, content:userText}]
```

### 5.5 Onboarding 状态机

```
            ┌─────────┐ 录音/跳过  ┌──────────┐ 动画结束 ┌──────────┐
  start ───►│ FINDING │──────────►│  REVEAL  │────────►│  NAMING  │
            └─────────┘           └──────────┘          └────┬─────┘
                                                             │ 提交名字
                                                             ▼
       ┌──────┐  关闭   ┌──────────┐  订阅成功/关闭   ┌────────────┐
       │ HOME │◄────────│ PAYWALL  │◄────────────────│ (命名完成)  │
       └──────┘         └──────────┘                  └────────────┘
```

- 每步进度持久化到 `Keychain/UserDefaults`，杀进程后恢复到最近安全步（不回退已命名）。
- `PAYWALL → HOME` 关闭路径必须可达（PRD FR-4.9/4.10）。

---

## 6. 模块实现：Notebook

### 6.1 API

| Method | Path | 说明 |
|---|---|---|
| GET | `/notebook/entries?cursor=&limit=20` | 倒序列表 |
| GET | `/notebook/entries/{entry_id}` | 详情 |
| POST | `/notebook/entries/{entry_id}/share` | 生成分享图，返回 url |

```jsonc
// GET /notebook/entries 响应
{ "ok": true, "data": {
  "items": [
    { "entry_id": "...", "entry_date": "2026-05-16", "author_name": "Stan",
      "illustration_url": "https://cdn/.../i.jpg",
      "body_preview": "昨天的练习留下一种自豪…",   // 服务端截断 ~120 字
      "affirmation": "我可以用勇气说出真实感受。",
      "is_locked": false },
    { "entry_id": "...", "entry_date": "2026-05-15", "is_locked": true }  // 锁定项不下发 body
  ],
  "next_cursor": "eyJkIjoiMjAyNi0wNS0xNCJ9" } }
```

### 6.2 锁定逻辑（服务端）

```
function serializeEntry(entry, viewer):
    locked = (viewer.plan == "free") AND (entry.entry_date < today() - FREE_WINDOW_DAYS)
    if locked:
        return { entry_id, entry_date, is_locked:true }   # 不含 body/illustration
    else:
        return fullEntry(entry)
```

- 锁定项**不下发正文**（防客户端绕过模糊层），客户端只渲染 `LockedOverlay`。

### 6.3 客户端组件树

```
NotebookView
├── NavBar(title:"NOTEBOOK", privacyLock)
└── StateContainer<[NotebookEntry]>
    └── List
        ├── AffirmationCard(affirmation)        // 渐变 banner
        └── EntryCard(entry)
            ├── if entry.is_locked → LockedOverlay(onTap: router.paywall(.notebook))
            └── else → Illustration + BodyPreview + Footer(author,date)
```

### 6.4 日记生成（异步，见 §13）

每日定时任务，逐用户：取当日 messages → LLM 生成 `body + affirmation` → 文生图生成插画 → 落 `notebook_entries`（`UNIQUE(user_id,entry_date)` 防重）。当日 user 消息数 < 阈值（如 4）则跳过当天（PRD FR-5.1.11）。

---

## 7. 模块实现：Intentions

### 7.1 API

| Method | Path | 说明 |
|---|---|---|
| GET | `/intentions/path` | path + 当前/历史 milestone |
| POST | `/intentions` | 选定今日意图 |
| POST | `/intentions/{id}/complete` | 完成意图（幂等） |

### 7.2 里程碑状态机

```
 locked ──(前一 milestone done)──► active ──(done_count ≥ required_count)──► done
                                      │                                      │
                                      └── 完成意图推进 progress_done ◄────────┘
```

### 7.3 完成意图 → 里程碑结算（事务伪代码）

```
POST /intentions/{id}/complete   [Idempotency-Key 必带]

BEGIN TRANSACTION
  intention = SELECT ... FOR UPDATE WHERE intention_id = id AND user_id = me
  IF intention.status == 'done': ROLLBACK; RETURN 409 CONFLICT   # 幂等
  intention.status = 'done'

  milestone = SELECT ... FOR UPDATE WHERE milestone_id = intention.milestone_id
  doneCount = COUNT(intentions WHERE milestone_id = milestone.id AND status='done')
  path.progress_done += 1

  rewardGranted = false
  IF doneCount >= milestone.required_count AND milestone.status == 'active':
      milestone.status = 'done'
      # 发奖：T 币入账（同事务，保证一致性）
      grantTokens(user_id, milestone.reward_tokens,
                  source='intention', ref=milestone.id, idem=IdemKey+":reward")
      # 解锁下一关
      UPDATE milestones SET status='active'
        WHERE path_id=path.id AND idx=milestone.idx+1 AND status='locked'
      rewardGranted = true
COMMIT

IF rewardGranted:
    enqueue(generateSketch, milestone.id)        # 异步生成速写图
RETURN { milestone_status, progress_done, reward_granted }
```

`grantTokens` 内部：`UPDATE wallets SET token_balance = token_balance + amount` + `INSERT token_transactions`，`idempotency_key` 唯一约束兜底重复发奖。

### 7.4 客户端

- `MilestoneCard` 三态渲染：`locked`（灰占位+锁+解锁条件）/`active`（进度格可点）/`done`（速写图+完成勾选）。
- 完成意图后乐观更新进度格，失败回滚并 toast。

---

## 8. 模块实现：Friends

### 8.1 API

| Method | Path | 说明 |
|---|---|---|
| POST | `/invites` | 创建邀请，返回 link_code + 分享文案 |
| GET | `/invites/summary` | 已邀人数 / 累计 T 币 |
| POST | `/invites/redeem` | 被邀请者注册时回传 link_code（注册流程内部调用） |

### 8.2 邀请链接与归因

- 链接形如 `https://tolan.ai/i/{link_code}`，`link_code` = 8 位 base62。
- 落地页：已装 App → Universal Link 直达；未装 → App Store + 剪贴板/`SKAdNetwork` 延迟深链。
- 注册成功后 `redeem` 写 `invites.invitee_id` 并触发风控。

### 8.3 防刷 + 发奖

```
function onInviteeRegistered(invite, invitee):
    risk = fraudCheck(
        sameDeviceId(invite.inviter, invitee),     # 设备指纹
        samePaymentAccount(...),                   # 支付账号
        ipClustering(...),                         # IP 聚集
        accountAgeAndBehavior(invitee))            # 行为异常
    IF risk.blocked:
        invite.status = 'rejected'
        emit('invite_fraud_blocked', invite_id)
        RETURN

    BEGIN TRANSACTION
      invite.status = 'rewarded'
      grantTokens(invite.inviter_id, 1000, source='invite', ref=invite.id,
                  idem='invite-reward:'+invite.id)         # 幂等键防重
      grantTrial(invitee.user_id, plan='lite', days=3)
    COMMIT
    push(invite.inviter_id, "你的朋友加入了，+1000 T！")
```

---

## 9. 模块实现：Insights

### 9.1 API

| Method | Path | 说明 |
|---|---|---|
| GET | `/insights/overview` | 星座图数据 + 5 维计数 + 距下次 reading |
| GET | `/insights?dimension=&cursor=` | 洞察列表 |
| GET | `/insights/readings` | 已解锁 reading |
| POST | `/insights/activity` | 取某维度 Start activity 提示语 |

```jsonc
// GET /insights/overview
{ "ok": true, "data": {
  "dimensions": [
    { "key": "drive",       "count": 3, "shape_level": 0.42 },
    { "key": "wellbeing",   "count": 2, "shape_level": 0.30 },
    { "key": "creativity",  "count": 1, "shape_level": 0.15 },
    { "key": "curiosity",   "count": 1, "shape_level": 0.15 },
    { "key": "relationships","count": 1,"shape_level": 0.15 }
  ],
  "total_insights": 8,
  "next_reading_at": 12,          // 累计满 12 条解锁
  "insights_to_next": 4 } }
```

### 9.2 洞察抽取（异步）

对话结束事件 `turn_done` 触发（或每日批量）：

```
function extractInsights(user_id, conversation):
    candidates = LLM_extract(conversation, prompt=INSIGHT_PROMPT)
        # 输出: [{ dimension, text, source_msg_id }]
    for c in candidates:
        # 去重：与该用户该维度已有洞察做语义相似度比对
        if maxCosine(embed(c.text), existingInsights(user_id, c.dimension)) > 0.88:
            continue
        INSERT insights(...)
        DimensionProfile[c.dimension].count++
        recomputeShapeLevel(c.dimension)          # shape_level = f(count)

    total = sum(counts)
    if total >= nextReadingThreshold(user_id):
        enqueue(generateReading, user_id)
```

- `shape_level`：归一映射，如 `min(1.0, count / 20)`，驱动星座图多边形大小/亮度。

### 9.3 星座图渲染（客户端）

- 5 个维度按固定角度（72° 等分）布点。
- 每维一个多边形，顶点数/半径由 `shape_level` 决定，发光强度同理。
- 用 `Canvas` 或 SceneKit 粒子；`shape_level` 变化时 `withAnimation` 形变过渡。

---

## 10. 模块实现：Check-Ins

### 10.1 API

| Method | Path | 说明 |
|---|---|---|
| POST | `/checkins/parse` | 自然语言 → 结构化调度（预览，不落库） |
| POST | `/checkins` | 确认创建 |
| GET | `/checkins` | 列表 |
| PATCH | `/checkins/{id}` | 暂停/恢复/删除 |

### 10.2 自然语言解析

```jsonc
// POST /checkins/parse  请求
{ "raw_text": "Call me on Sunday afternoons" }

// 响应
{ "ok": true, "data": {
  "schedule_kind": "recurring",
  "cron_expr": "0 15 * * SUN",       // 解析为本地时周日 15:00
  "channel": "call",
  "human_readable": "每周日下午 3 点给你打电话",
  "confidence": 0.91,
  "needs_clarification": false } }
```

解析实现：LLM + 函数调用，schema 约束输出 `{schedule_kind, cron_expr|fire_at, channel}`。`confidence < 0.6` 或字段缺失 → `needs_clarification:true`，客户端追问。

### 10.3 调度与触达

```
-- 每分钟扫描 due 任务（ix_checkin_due 索引）
SELECT * FROM check_ins
 WHERE status='active' AND next_fire_at <= now()
 FOR UPDATE SKIP LOCKED LIMIT 500;

for each ci:
    user = loadUser(ci.user_id)
    if inQuietHours(user, now()) and ci.channel in ('call','sms'):
        ci.next_fire_at = nextSlotAfterQuietHours(...)   # 顺延，不打扰
        continue
    if ci.channel in ('call','sms') and not user.phone_verified:
        ci.channel = 'push'                              # 降级
    content = LLM_generate(CHECKIN_VOICE_PROMPT, user)   # 角色化内容
    dispatch(ci.channel, user, content)                  # Twilio / APNs
    emit('checkin_fired', ci.id, ci.channel)
    if ci.schedule_kind == 'recurring':
        ci.next_fire_at = cronNext(ci.cron_expr, user.timezone)
    else:
        ci.status = 'completed'
```

- `inQuietHours` 读 `user_settings.quiet_hours_*`（PRD FR-5.5.8 / FR-5.9.9）。
- 用户点开由 check-in 唤起的 App → `app_launch{source:checkin}` 埋点，计召回回流。

---

## 11. 模块实现：Shop

### 11.1 API

| Method | Path | 说明 |
|---|---|---|
| GET | `/shop/items?category=clothes&sub=tops` | 商品目录（ETag 缓存） |
| GET | `/shop/inventory` | 用户库存 |
| POST | `/shop/purchase` | 购买（事务 + 幂等） |
| POST | `/shop/equip` | 装备/卸下 |

### 11.2 购买事务（关键：扣费与发货原子性，PRD NFR-SEC.3）

```
POST /shop/purchase  { "item_id": "..." }   [Idempotency-Key 必带]

BEGIN TRANSACTION
  -- 幂等：同 key 已成功则直接返回原结果
  IF exists(token_transactions WHERE idempotency_key = key):
      ROLLBACK; RETURN cachedResult(key)

  item   = SELECT * FROM shop_items WHERE item_id = ? 
  IF item is null OR not nowInWindow(item): ROLLBACK; RETURN 404
  IF exists(user_inventory WHERE user_id=me AND item_id=item):
      ROLLBACK; RETURN 409 ALREADY_PURCHASED

  wallet = SELECT * FROM wallets WHERE user_id = me FOR UPDATE   -- 行锁
  IF wallet.token_balance < item.price_tokens:
      ROLLBACK; RETURN 402 INSUFFICIENT_TOKENS { shortfall: ... }

  UPDATE wallets SET token_balance = token_balance - item.price_tokens
  INSERT token_transactions(type='spend', source='shop', amount=item.price_tokens,
        balance_after=newBalance, ref_id=item.id, idempotency_key=key)
  INSERT user_inventory(user_id=me, item_id=item.id, equipped=false)
COMMIT

RETURN { ok:true, balance_after, item_id }
```

### 11.3 客户端实时预览

- 进入商店：拉 `inventory` + `items`，本地构建 `已拥有 Set`。
- 点击商品 → 立即在 `CharacterPreview`（SceneKit 节点）替换对应槽位贴图/网格，**不落库**。
- 购买成功 → 标记已拥有 → 自动 `equip`。
- 取消预览/退出未购买 → 还原为已装备态。

### 11.4 边界

- 余额不足：购买按钮 `disabled`，副标题显示差额，旁置"获取 T 币"。
- 购买请求失败：不修改本地余额（本就以服务端返回为准），toast 重试。
- 限定商品过期：目录不再返回，但 `inventory` 中已购项仍可 `equip`。

---

## 12. 模块实现：Settings

### 12.1 API

| Method | Path | 说明 |
|---|---|---|
| GET | `/me/settings` | 拉全部设置 |
| PATCH | `/me/settings` | 部分更新（字段级） |
| GET | `/me/subscription` | 订阅状态（服务端校验 App Store） |
| POST | `/me/subscription/restore` | 恢复购买：校验票据 |
| POST | `/me/phone/verify` | 手机号验证码流程 |
| POST | `/me/data-export` | 发起数据导出（异步，完成后邮件/下载链接） |
| POST | `/me/memory/clear` | 清除长期记忆（二次确认） |
| POST | `/me/account/delete` | 删除账户 |

### 12.2 PATCH 字段级更新

```jsonc
// PATCH /me/settings  —— 只传要改的字段
{ "quiet_hours_start": 1320, "quiet_hours_end": 480 }   // 22:00–08:00（分钟）
```

服务端只更新出现的字段；`tolan_name` 变更需广播失效缓存（主页、日记署名快照不追溯改旧数据）。

### 12.3 删除账户流程

```
POST /me/account/delete
  1. 校验：进行中的删除请求不可重复提交 → 409
  2. 创建 account_deletion_requests(status='pending')
  3. users.deleted_at = now()；立即登出所有 session（吊销 refresh token）
  4. 异步 job（合规宽限期，如 T+30d）：物理清除
       messages / memories / notebook_entries / insights / ...
       S3 资产（插画/语音/导出包）
  5. 保留最小合规审计记录（不含个人内容）
```

- 客户端：删除前弹角色化挽留一次 + 不可逆警告 + 明确确认（PRD FR-5.9.13/14）。挽留不得设计成多步障碍。

### 12.4 恢复购买

```
POST /me/subscription/restore
  StoreKit2: 客户端 currentEntitlements → 取最新有效交易
  → 上送 jwsRepresentation
  服务端用 Apple 公钥验签 → 解析 plan/expires_at → upsert subscriptions
  → 返回最新订阅状态
```

---

## 13. 异步任务与调度

| 任务 | 触发 | 频率 | 幂等保证 |
|---|---|---|---|
| 记忆抽取 | `turn_done` 事件 | 每轮对话 | `source_msg_id` 去重 |
| 洞察抽取 | `turn_done` / 每日批 | 对话后 | 语义相似度去重（§9.2） |
| 日记生成 | Cron 逐用户本地 ~04:00 | 每日 | `UNIQUE(user_id,entry_date)` |
| 速写生成 | `milestone_done` | 按需 | `milestone_id` 已有则跳过 |
| Reading 生成 | 洞察阈值达标 | 按需 | `UNIQUE(user_id,idx)` |
| Check-in 触达 | 扫描 `next_fire_at` | 每分钟 | `FOR UPDATE SKIP LOCKED` |
| daily_usage 对账 | Cron | 每小时 | upsert |
| 账户物理清除 | 删除请求 + 宽限期 | 每日 | 状态机 |

调度器实现：Cron 任务用分布式锁（Redis `SET NX`）保证单实例执行；延迟任务（once 型 check-in）用延迟队列。所有任务必须可重入（幂等）。

---

## 14. 内容安全实现

对应 PRD §9.5。**双重检测**：用户输入侧 + 模型输出侧。

```
function safetyPipeline(user_id, userText, ctx):
    # 1. 输入侧危机识别（独立轻量分类器，低延迟）
    risk = crisisClassifier(userText)   # self_harm / suicide / violence / abuse
    if risk.level >= HIGH:
        resources = crisisResources(localeOf(user_id))   # 本地化热线
        sendFrame('crisis_detected', { resources })
        # 仍走 LLM，但注入强约束 system 指令：关怀、不评判、引导求助
        ctx.system += CRISIS_RESPONSE_DIRECTIVE

    # 2. 生成
    output = LLM.stream(ctx)

    # 3. 输出侧过滤（流式时分段过滤；命中则截断并改写）
    for chunk in output:
        if outputModeration(chunk).blocked:   # 仇恨/色情/有害指引/自称真人
            replaceWithSafeFallback()
            break
        yield chunk
```

- 危机资源入口同时常驻"关于/帮助"页（PRD FR-5.9.19），不依赖实时检测。
- Tolan 不得自称人类或持证心理专业人士（PRD NFR-SAFE.5）——写入 `SAFETY_RULES` system 段。
- 危机事件记录元数据用于复盘，**不记录对话原文**（NFR-DATA.2 / NFR-PRIV）。

---

## 15. 可观测性与发布

### 15.1 埋点上报

- 客户端事件批量上报 `POST /events`（≤ 50 条/批，失败本地暂存重试）。
- 统一字段：`event`、`user_id`、`ts`、`app_version`、`platform`、`trace_id` + 事件参数。
- 服务端事件直接进 Kafka。

### 15.2 关键监控指标

| 指标 | 告警阈值（示例） |
|---|---|
| 语音首 token 延迟 P95 | > 2.5s |
| LLM 调用错误率 | > 2% |
| 购买事务失败率 | > 0.5% |
| check-in 触达成功率 | < 98% |
| WS 异常断连率 | > 5% |

### 15.3 发布

- 灰度：按 `user_id` 哈希分桶，新功能 Feature Flag 控制。
- 数据库变更：先加列（可空）→ 双写 → 回填 → 切读 → 删旧列，分多次发布。
- 回滚：客户端 Feature Flag 秒级关闭；服务端保留上一镜像。

---

## 附录：实现优先级（对齐 PRD MVP）

| 实现项 | 所属里程碑 | 说明 |
|---|---|---|
| §2 通用约定 + §3 Schema 核心表 | M1 | 地基 |
| §5 聊天流式 + 配额 | M1 | 核心闭环 |
| §6 Notebook + §13 日记生成 | M1 | 内容沉淀 |
| §7 Intentions + §9 Insights | M2 | 成长系统 |
| §11 Shop + T 币事务 | M2 | 经济 |
| §10 Check-Ins + §8 Friends | M3 | 召回 + 裂变 |
| §12 Settings 合规项 | M1 | 删除账户/导出/订阅为 M1 必须 |
| §14 内容安全 | M1 | 所有 P0，不可延后 |

---

> **文档版本**：v1.0 · 2026-05-19
> **配套**：[TOLAN_PRD.md](TOLAN_PRD.md) v2.0
> **说明**：技术栈为合理假设，接口契约与算法逻辑可直接作为开发依据；落地前需与团队实际架构对齐。
