# 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`。