506 lines
18 KiB
Markdown
506 lines
18 KiB
Markdown
# 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`。
|