Files
eryao/docs/plans/2026-04-03-user-points-chat-design.md
T

506 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Eryao 用户档案/积分/会话数据模型设计
日期:2026-04-03
状态:已确认(待实现)
## 1. 目标与范围
本设计用于 Eryao 后端新增并对齐以下 5 张表:
1. 用户档案表:`profiles`
2. 用户积分表:`user_points`
3. 积分流水表:`points_ledger`
4. 会话表:`sessions`
5. 对话历史表:`messages`
来源原则:
- `profiles``sessions``messages` 参考并吸收 `social-app` 现有设计。
- 会话能力按“结构完整复制,但业务先停用 automation”执行。
- 本文档为设计方案,不包含迁移脚本与代码实现。
## 2. 关键确认项
### 2.1 profiles.username 不做唯一约束
已确认:`profiles.username` **不需要唯一**
设计落地:
- 不创建 `UNIQUE(username)` 约束。
- 可保留普通索引 `ix_profiles_username` 以支持检索。
- 若后续产品要支持“唯一用户名登录/提及”,另行引入唯一标识字段(例如 `handle`)。
### 2.2 settings 需要 JSONB 模板
`profiles.settings` 使用 `jsonb not null default '{}'::jsonb`,并约定版本化模板:
```json
{
"version": 1,
"preferences": {
"interface_language": "zh-CN",
"ai_language": "zh-CN",
"timezone": "Asia/Shanghai",
"country": "CN"
},
"privacy": {
"profile_visibility": "public",
},
"notification": {
"push_enabled": true,
},
}
```
说明:
- `version` 为配置结构版本,后续结构升级通过版本迁移处理。
- `timezone` 作为运行时时区回退来源之一。
- `default_runtime_mode` 当前仅允许 `chat` 生效。
## 3. 表结构设计
## 3.1 profiles(吸收 social-app
核心字段:
- `id uuid primary key`(外键指向 `auth.users(id)``on delete cascade`
- `username varchar(30) not null`(非唯一)
- `avatar_url text null`
- `bio varchar(200) null`
- `settings jsonb not null default '{}'::jsonb`
- `created_at timestamptz not null default now()`
- `updated_at timestamptz not null default now()`
- `deleted_at timestamptz null`
索引建议:
- `ix_profiles_username (username)`
- `ix_profiles_settings_gin using gin(settings)`
初始化建议:
-`auth.users` 建立注册触发器,自动插入 profile 默认记录。
- `settings` 初始化值应写入上述模板(而非空对象)。
## 3.2 user_points(用户积分账户)
职责:保存用户积分余额与累计统计,1 用户 1 行。
核心字段:
- `user_id uuid primary key`FK `auth.users(id)`
- `balance bigint not null default 0`
- `frozen_balance bigint not null default 0`
- `lifetime_earned bigint not null default 0`
- `lifetime_spent bigint not null default 0`
- `version int not null default 0`
- `updated_at timestamptz not null default now()`
约束建议:
- `check (balance >= 0)`
- `check (frozen_balance >= 0)`
- `check (lifetime_earned >= 0)`
- `check (lifetime_spent >= 0)`
## 3.3 points_ledger(积分流水)
职责:记录每次积分变更,支持审计、对账、幂等。
核心字段:
- `id uuid primary key`
- `user_id uuid not null`FK `auth.users(id)`
- `direction smallint not null`1 增加,-1 减少)
- `amount bigint not null`
- `balance_after bigint not null`
- `change_type varchar(16) not null`(约束:`register/consume/grant/adjust`
- `biz_type varchar(16) not null`(约束:当前仅 `chat`
- `biz_id uuid not null`(当前语义:指向 `sessions.id`
- `event_id varchar(64) not null`
- `operator_id uuid null`
- `metadata jsonb not null default '{}'::jsonb`
- `created_at timestamptz not null default now()`
约束与索引建议:
- `check (amount > 0)`
- `check (direction in (1, -1))`
- `check (change_type in ('register', 'consume', 'grant', 'adjust'))`
- `check (biz_type = 'chat')`
- `foreign key (biz_id) references sessions(id)`
- `unique (user_id, event_id)`(用户维度幂等)
- `index (user_id, created_at desc)`
- `index (biz_type, biz_id)`
## 3.4 sessions(完整复制结构,先停用 automation
来源:`social-app``sessions` 表结构。
核心字段:
- `id uuid primary key`
- `user_id uuid not null`
- `session_type varchar(20) not null`(结构保留 `chat/automation`
- `job_id uuid null`
- `title varchar(255) null`
- `status varchar(20) not null`
- `last_activity_at timestamptz not null default now()`
- `message_count int not null default 0`
- `total_tokens int not null default 0`
- `total_cost numeric(12,6) not null default 0`
- `state_snapshot jsonb null`
- `created_at/updated_at/deleted_at`
业务启用策略(当前阶段):
- 应用层仅允许 `session_type='chat'`
- 应用层要求 `job_id is null`
- 数据结构不删减,保留未来 automation 扩展能力。
## 3.5 messages(完整复制结构)
来源:`social-app``messages` 表结构。
核心字段:
- `id uuid primary key`
- `session_id uuid not null`FK `sessions(id)``on delete cascade`
- `seq int not null`
- `role varchar(20) not null``user/assistant/system/tool`
- `content text not null`
- `model_code varchar(50) null`
- `tool_name varchar(100) null`
- `input_tokens int not null default 0`
- `output_tokens int not null default 0`
- `cost numeric(12,6) not null default 0`
- `latency_ms int null`
- `visibility_mask bigint not null default 0`
- `metadata jsonb null`
- `created_at/updated_at/deleted_at`
约束与索引建议:
- `unique (session_id, seq)`
- `index (session_id)`
- `index (session_id, seq, visibility_mask)`
## 4. 一致性与事务约定
- 积分变更必须在单事务内同时更新:`user_points` + `points_ledger`
- 通过 `event_id` 做幂等写保护,避免重试导致重复扣发。
- `sessions.total_tokens/total_cost/message_count` 作为聚合字段,由写消息流程维护。
## 5. 安全与权限
- 所有业务写入走后端服务层,不信任客户端传入 `owner_id/user_id`
- 表级策略沿用项目约定(RLS + 服务端授权控制)。
- `metadata/settings` 禁止写入密钥类敏感信息。
## 6. 兼容与演进
- 本期兼容策略:新增表/字段为主,不做破坏式变更。
- automation 能力延后启用,仅在业务层放开,不需变更当前 DDL。
- 若后续需要唯一用户名,应新增独立唯一字段,不直接改造 `username` 历史数据。
## 7. 关于“用户实际成本核算表”的结论
结论:建议二期引入,不阻塞本期 5 张表上线。
理由:
- 本期已有 `messages.cost``sessions.total_cost`,可支持展示级统计。
- 若进入财务对账、补贴结算、重算审计场景,需要独立不可变成本流水表。
建议二期最小表:`user_cost_ledger`,记录 provider/model/tokens/raw_cost/billable_cost/event_id。
## 8. 字段释义(5 张表逐字段)
本节作为实施、联调、排障时的字段字典,避免同名字段被不同团队误读。
### 8.1 profiles
- `id`:用户主键,直接对应 `auth.users.id`,生命周期与认证用户绑定。
- `username`:展示名/昵称,不承担唯一身份语义。
- `avatar_url`:头像地址。
- `bio`:用户简介。
- `settings`:用户配置 JSON,承载语言、时区、隐私、通知等可扩展偏好。
- `created_at`:记录创建时间。
- `updated_at`:最近一次更新记录时间。
- `deleted_at`:软删除时间,`null` 表示有效。
### 8.2 user_points
- `user_id`:积分账户所属用户,1:1 对应 `auth.users.id`
- `balance`:当前可计入总余额的积分值(含可用与冻结)。
- `frozen_balance`:冻结中的积分,暂不可消费。
- `lifetime_earned`:历史累计获得积分(单调递增)。
- `lifetime_spent`:历史累计消费积分(单调递增)。
- `version`:乐观锁版本号,用于并发更新防冲突。
- `updated_at`:积分账户最近一次变更时间。
### 8.3 points_ledger
- `id`:流水主键。
- `user_id`:该条积分流水所属用户。
- `direction`:变更方向,`1` 表示加分,`-1` 表示扣分。
- `amount`:变更绝对值,始终为正数。
- `balance_after`:本次变更完成后的账户余额快照。
- `change_type`:变更分类,仅允许 `register/consume/grant/adjust`
- `biz_type`:业务域类型,当前固定 `chat`
- `biz_id`:业务侧引用 ID,当前固定引用 `sessions.id`
- `event_id`:幂等事件 ID,同一用户下不可重复。
- `operator_id`:操作人(系统/管理员/服务账号)用户 ID,可空。
- `metadata`:扩展信息 JSON(上下文参数、备注、来源等)。
- `created_at`:流水写入时间。
### 8.4 sessions
- `id`:会话主键。
- `user_id`:会话所属用户。
- `session_type`:会话类型,当前只启用 `chat`,结构保留 `automation`
- `job_id`:自动化任务 ID(当前阶段应为 `null`)。
- `title`:会话标题。
- `status`:会话状态(如 active/archived/closed)。
- `last_activity_at`:最近活动时间,用于排序与回收策略。
- `message_count`:消息总数聚合值。
- `total_tokens`:会话累计 token 聚合值。
- `total_cost`:会话累计成本聚合值。
- `state_snapshot`:会话状态快照(用于上下文恢复/调试)。
- `created_at`:创建时间。
- `updated_at`:更新时间。
- `deleted_at`:软删除时间。
### 8.5 messages
- `id`:消息主键。
- `session_id`:所属会话 ID,级联删除。
- `seq`:会话内消息序号(从小到大单调)。
- `role`:消息角色(`user/assistant/system/tool`)。
- `content`:消息主体文本。
- `model_code`:生成该消息的模型标识。
- `tool_name`:工具消息对应工具名。
- `input_tokens`:本条请求输入 token。
- `output_tokens`:本条响应输出 token。
- `cost`:本条消息成本。
- `latency_ms`:本条消息处理耗时(毫秒)。
- `visibility_mask`:可见性位掩码,用于多视图过滤。
- `metadata`:扩展信息 JSON。
- `created_at`:创建时间。
- `updated_at`:更新时间。
- `deleted_at`:软删除时间。
## 9. 审查结论(重点:user_points / points_ledger
结论:当前字段集可支撑一期上线,但若目标是“高并发 + 强审计 + 低误用”,建议在 DDL 层补 4 项硬约束、1 项审计字段,能显著降低后续事故概率。
### 9.1 user_points 审查
现状可用点:
- 账户余额、冻结、累计收支、版本号齐全,满足账户模型最小闭环。
- 非负约束已覆盖核心数值字段,能防止明显脏数据。
主要风险与建议:
1. 缺少 `frozen_balance <= balance` 约束。
- 风险:可能出现“冻结金额大于总余额”的不合法状态。
- 建议:新增 `check (frozen_balance <= balance)`
2. 缺少 `created_at`
- 风险:无法直接追溯账户初始化时间,审计链不完整。
- 建议:新增 `created_at timestamptz not null default now()`
3. 并发写依赖应用层版本控制,需明确 SQL 写法。
- 风险:若更新语句未携带 `version` 条件,可能发生覆盖写。
- 建议:约定更新模板 `... where user_id=? and version=?`,成功后 `version=version+1`
### 9.2 points_ledger 审查
现状可用点:
- `direction + amount + balance_after + event_id` 组合,已具备审计、幂等、对账基础能力。
- `(user_id, event_id)` 唯一约束符合“同一用户维度幂等”场景。
主要风险与建议:
1. 缺少 `balance_after >= 0` 约束。
- 风险:极端并发或逻辑 bug 时可能落负余额快照。
- 建议:新增 `check (balance_after >= 0)`
2. `operator_id` 未声明外键语义。
- 风险:排障时难确认操作者主体是否存在。
- 建议:若业务允许,增加 FK `operator_id -> auth.users(id)`(可 `on delete set null`)。
3. `change_type/biz_type` 为自由文本。
- 风险:枚举漂移(同义不同写)导致统计口径分裂。
- 建议:通过 `check in (...)` 或字典表约束可选值。
4. 缺少“业务发生时间”字段。
- 风险:`created_at` 仅表示入库时间,异步补偿场景下难对齐业务时序。
- 建议:二期可加 `occurred_at timestamptz`
### 9.3 一期最低增强清单(建议)
若只做最小改动,优先加以下 5 项:
1. `user_points`: `check (frozen_balance <= balance)`
2. `user_points`: `created_at timestamptz not null default now()`
3. `points_ledger`: `check (balance_after >= 0)`
4. `points_ledger`: 明确 `operator_id` 外键策略。
5. 统一 `change_type/biz_type` 枚举口径(约束或字典表)。
## 10. points_ledger 约束模型(定稿草案)
本节将 `change_type``biz_type``metadata` 固化为可执行约束,作为后续 DDL 实现依据。
### 10.1 change_type / biz_type / biz_id 约束
- `change_type``register | consume | grant | adjust`
- `biz_type`:当前仅允许 `chat`
- `biz_id``uuid not null`,并 `FK -> sessions(id)`
配套业务约束建议:
- `register/grant` 必须 `direction = 1`
- `consume` 必须 `direction = -1`
- `adjust` 允许 `direction in (1, -1)`
建议 SQL(可直接迁移化):
```sql
alter table points_ledger
add constraint ck_points_ledger_change_type
check (change_type in ('register', 'consume', 'grant', 'adjust')),
add constraint ck_points_ledger_biz_type
check (biz_type = 'chat'),
add constraint ck_points_ledger_direction_by_change_type
check (
(change_type in ('register', 'grant') and direction = 1)
or (change_type = 'consume' and direction = -1)
or (change_type = 'adjust' and direction in (1, -1))
),
add constraint fk_points_ledger_biz_session
foreign key (biz_id) references sessions(id);
```
### 10.2 metadata 结构(基于现有 chat 数据的定制模型)
设计依据(来自当前代码里的真实字段):
- `messages.metadata` 已稳定存在 `run_id`(见 `AgentChatMessageMetadata.run_id`)。
- `messages` 表已有计费上下文列:`id/seq/model_code/input_tokens/output_tokens/cost`
- chat 业务主键是 `session_id`,本设计里已对应 `points_ledger.biz_id`
因此,`points_ledger.metadata` 不再使用泛化字段,直接锚定现有运行时和消息数据:
```json
{
"schema_version": 1,
"reason_code": "REGISTER_WELCOME|CHAT_CONSUME|CHAT_GRANT|CHAT_ADJUST",
"operator_type": "user|system|admin",
"run_id": "string",
"request_id": "string|null",
"charge": {
"message_id": "uuid",
"message_seq": 1,
"model_code": "string",
"input_tokens": 0,
"output_tokens": 0,
"cost": "0.000000"
},
"ext": {}
}
```
字段说明(按现有数据来源):
- `schema_version`:固定 `1`
- `reason_code`:固定业务原因码,不允许自由文本。
- `operator_type`:与 `operator_id` 搭配使用,表达操作者身份类型。
- `run_id`:来自 agent 运行主键(`messages.metadata.run_id` 同源)。
- `request_id`:来自 `X-Request-ID`(可空,排障用)。
- `charge`:消费/赠金/调整时的“消息快照”,字段全部来自 `messages` 现有列。
- `ext`:仅允许对象,承载少量扩展审计信息(如工单号)。
`change_type` 的必填规则(不是通用模板,直接按你当前业务):
- `register`:必须有 `reason_code/operator_type/run_id``charge` 必须不存在。
- `consume`:必须有 `reason_code/operator_type/run_id/charge`,且 `charge.message_id/message_seq/model_code/input_tokens/output_tokens/cost` 全必填。
- `grant`:必须有 `reason_code/operator_type/run_id`;若是“按会话补偿赠金”,允许并建议带 `charge`
- `adjust`:必须有 `reason_code/operator_type/run_id``ext.ticket_id``charge` 可选。
建议 SQL(JSON 约束可执行最小集):
```sql
alter table points_ledger
add constraint ck_points_ledger_metadata_object
check (jsonb_typeof(metadata) = 'object'),
add constraint ck_points_ledger_metadata_common
check (
metadata->>'schema_version' = '1'
and metadata->>'reason_code' in ('REGISTER_WELCOME', 'CHAT_CONSUME', 'CHAT_GRANT', 'CHAT_ADJUST')
and metadata->>'operator_type' in ('user', 'system', 'admin')
and coalesce(metadata->>'run_id', '') <> ''
and (not (metadata ? 'ext') or jsonb_typeof(metadata->'ext') = 'object')
),
add constraint ck_points_ledger_metadata_register_shape
check (
change_type <> 'register'
or (
metadata->>'reason_code' = 'REGISTER_WELCOME'
and not (metadata ? 'charge')
)
),
add constraint ck_points_ledger_metadata_consume_shape
check (
change_type <> 'consume'
or (
metadata->>'reason_code' = 'CHAT_CONSUME'
and (metadata ? 'charge')
and jsonb_typeof(metadata->'charge') = 'object'
and (metadata->'charge' ? 'message_id')
and (metadata->'charge' ? 'message_seq')
and (metadata->'charge' ? 'model_code')
and (metadata->'charge' ? 'input_tokens')
and (metadata->'charge' ? 'output_tokens')
and (metadata->'charge' ? 'cost')
)
),
add constraint ck_points_ledger_metadata_adjust_shape
check (
change_type <> 'adjust'
or (
metadata->>'reason_code' = 'CHAT_ADJUST'
and (metadata ? 'ext')
and (metadata->'ext' ? 'ticket_id')
and coalesce(metadata #>> '{ext,ticket_id}', '') <> ''
)
);
```
可选强化(建议二期加触发器,而不是只靠 CHECK):
- 校验 `metadata.charge.message_id` 真正存在于 `messages.id`,且 `messages.session_id = points_ledger.biz_id`
- 校验 `metadata.charge.message_seq` 与该 `message_id` 的真实 `seq` 一致。
### 10.3 operator_id 与 created_by/updated_by 是否重复
不重复,语义不同:
- `operator_id`:业务操作者(“谁触发了积分变更”),是业务审计字段。
- `created_by/updated_by`:数据行审计字段(“谁写了这条数据库记录”)。
`points_ledger`(不可变流水)而言:
- `updated_by` 基本无意义(流水不应更新)。
- `created_by` 常等于服务账号,无法表达真实业务操作者。
- 因此保留 `operator_id` 是必要的,且建议允许空值(纯系统任务)。
推荐实践:
- `points_ledger`:保留 `operator_id`,不强制引入 `created_by/updated_by`
- `user_points`:如项目需要统一审计基类,可在账户表引入 `updated_by`,但不替代流水里的 `operator_id`