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

18 KiB
Raw Blame History

Eryao 用户档案/积分/会话数据模型设计

日期:2026-04-03
状态:已确认(待实现)

1. 目标与范围

本设计用于 Eryao 后端新增并对齐以下 5 张表:

  1. 用户档案表:profiles
  2. 用户积分表:user_points
  3. 积分流水表:points_ledger
  4. 会话表:sessions
  5. 对话历史表:messages

来源原则:

  • profilessessionsmessages 参考并吸收 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 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 keyFK 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 nullFK auth.users(id)
  • direction smallint not null1 增加,-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-appsessions 表结构。

核心字段:

  • 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-appmessages 表结构。

核心字段:

  • id uuid primary key
  • session_id uuid not nullFK sessions(id)on delete cascade
  • seq int not null
  • role varchar(20) not nulluser/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.costsessions.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_typebiz_typemetadata 固化为可执行约束,作为后续 DDL 实现依据。

10.1 change_type / biz_type / biz_id 约束

  • change_typeregister | consume | grant | adjust
  • biz_type:当前仅允许 chat
  • biz_iduuid not null,并 FK -> sessions(id)

配套业务约束建议:

  • register/grant 必须 direction = 1
  • consume 必须 direction = -1
  • adjust 允许 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_idcharge 必须不存在。
  • 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_idext.ticket_idcharge 可选。

建议 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