18 KiB
Eryao 用户档案/积分/会话数据模型设计
日期:2026-04-03
状态:已确认(待实现)
1. 目标与范围
本设计用于 Eryao 后端新增并对齐以下 5 张表:
- 用户档案表:
profiles - 用户积分表:
user_points - 积分流水表:
points_ledger - 会话表:
sessions - 对话历史表:
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,并约定版本化模板:
{
"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 nullbio varchar(200) nullsettings jsonb not null default '{}'::jsonbcreated_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(FKauth.users(id))balance bigint not null default 0frozen_balance bigint not null default 0lifetime_earned bigint not null default 0lifetime_spent bigint not null default 0version int not null default 0updated_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 keyuser_id uuid not null(FKauth.users(id))direction smallint not null(1 增加,-1 减少)amount bigint not nullbalance_after bigint not nullchange_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 nulloperator_id uuid nullmetadata jsonb not null default '{}'::jsonbcreated_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 keyuser_id uuid not nullsession_type varchar(20) not null(结构保留chat/automation)job_id uuid nulltitle varchar(255) nullstatus varchar(20) not nulllast_activity_at timestamptz not null default now()message_count int not null default 0total_tokens int not null default 0total_cost numeric(12,6) not null default 0state_snapshot jsonb nullcreated_at/updated_at/deleted_at
业务启用策略(当前阶段):
- 应用层仅允许
session_type='chat'。 - 应用层要求
job_id is null。 - 数据结构不删减,保留未来 automation 扩展能力。
3.5 messages(完整复制结构)
来源:social-app 的 messages 表结构。
核心字段:
id uuid primary keysession_id uuid not null(FKsessions(id),on delete cascade)seq int not nullrole varchar(20) not null(user/assistant/system/tool)content text not nullmodel_code varchar(50) nulltool_name varchar(100) nullinput_tokens int not null default 0output_tokens int not null default 0cost numeric(12,6) not null default 0latency_ms int nullvisibility_mask bigint not null default 0metadata jsonb nullcreated_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 审查
现状可用点:
- 账户余额、冻结、累计收支、版本号齐全,满足账户模型最小闭环。
- 非负约束已覆盖核心数值字段,能防止明显脏数据。
主要风险与建议:
-
缺少
frozen_balance <= balance约束。- 风险:可能出现“冻结金额大于总余额”的不合法状态。
- 建议:新增
check (frozen_balance <= balance)。
-
缺少
created_at。- 风险:无法直接追溯账户初始化时间,审计链不完整。
- 建议:新增
created_at timestamptz not null default now()。
-
并发写依赖应用层版本控制,需明确 SQL 写法。
- 风险:若更新语句未携带
version条件,可能发生覆盖写。 - 建议:约定更新模板
... where user_id=? and version=?,成功后version=version+1。
- 风险:若更新语句未携带
9.2 points_ledger 审查
现状可用点:
direction + amount + balance_after + event_id组合,已具备审计、幂等、对账基础能力。(user_id, event_id)唯一约束符合“同一用户维度幂等”场景。
主要风险与建议:
-
缺少
balance_after >= 0约束。- 风险:极端并发或逻辑 bug 时可能落负余额快照。
- 建议:新增
check (balance_after >= 0)。
-
operator_id未声明外键语义。- 风险:排障时难确认操作者主体是否存在。
- 建议:若业务允许,增加 FK
operator_id -> auth.users(id)(可on delete set null)。
-
change_type/biz_type为自由文本。- 风险:枚举漂移(同义不同写)导致统计口径分裂。
- 建议:通过
check in (...)或字典表约束可选值。
-
缺少“业务发生时间”字段。
- 风险:
created_at仅表示入库时间,异步补偿场景下难对齐业务时序。 - 建议:二期可加
occurred_at timestamptz。
- 风险:
9.3 一期最低增强清单(建议)
若只做最小改动,优先加以下 5 项:
user_points:check (frozen_balance <= balance)。user_points:created_at timestamptz not null default now()。points_ledger:check (balance_after >= 0)。points_ledger: 明确operator_id外键策略。- 统一
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 | adjustbiz_type:当前仅允许chatbiz_id:uuid not null,并FK -> sessions(id)
配套业务约束建议:
register/grant必须direction = 1consume必须direction = -1adjust允许direction in (1, -1)
建议 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 不再使用泛化字段,直接锚定现有运行时和消息数据:
{
"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 约束可执行最小集):
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。