用户质问:「为什么背景是保存在本地而不是服务器!」当时的实情是主题与壁纸只写 localStorage:换设备/换浏览器就没了,而且**多账号共用一份**(键是全局常量 `agentmail.background`)—— 同一台机器换账号背景不跟着走。而 localStorage 的 ~5MB 配额也解释了客户端那套"压到 2.4MB 以内"的限制本来就是为本地存储设计的。 现在:**服务端是权威(账号级),本地只是缓存**(首屏秒开、离线可用)。 ## 服务端 - 新表 `user_appearance`(两种方言),用**列**而不是 JSON:blob GC 要一眼看出 "这张图还有没有人用"。 - `/api/v1/me/appearance`:GET / PUT(主题+背景档)/ POST image(multipart)/ GET image / DELETE image。鉴权同其余 /me/*(cookie 或 Bearer)。 - 图片走**内容寻址的 blob 存储**(与附件同一套),库里只存 sha256;上限 4MB 兜底 (客户端会先压到 ~2.4MB),只收图片类型(非图片 415 —— 浏览器会把非图片渲染成 空白,用户只会看到"设置了却没变化"),超限 413 不静默截断。 - ★ **blob GC 的引用源加了这张表**:我在实现前先读了 `SweepUnreferencedBlobs`, 它只认 attachments / calendar_attachments。漏了这一处,壁纸会在下次 GC 时被当 孤儿删掉,而库里那行还在 —— 表现为"图 404、设置却显示已设置"。判据同时验了 壁纸存活**与**孤儿确实被清(否则"还在"可能只是因为 GC 没跑)。 ## 客户端 - `lib/appearance.ts`(纯函数:两侧形状换算、data URL→Blob)+ `stores/appearanceSync.ts` (pull / push / 去抖订阅 / 账号切换重新拉取)。 - 三条不变量都有判据:拉取以服务端为准;★ **拉取不会再推回去**(否则是自触发回环, 一次拉取顺带一次 PUT,服务端 updated_at 被无意义刷新);本地改动会推上去。 - 壁纸**只在换图时上传一次**(几 MB 不该每次 PUT 都跟着走)。 - 降级**必须可见**:未登录/不可达 → `local-only`,推失败 → `pending`,背景设置里 有徽标与说明("已同步 / 待同步 / 仅本机")。静默降级会让人以为已经同步, 然后在另一台机器上发现没有 —— 正是这次的缺陷。 - 图片用**带认证的 fetch** 取回再转 data URL:`<img src>` 发不出 Bearer,而 `?token=` 会把密钥写进历史记录与服务端日志(明确不做)。 ## 判据 - Go 10 条:往返、★多账号隔离、非法值归一、上传/取回字节一致、非图片 415、 超限 413、删除、未登录 401(五个端点)、★GC 存活 + 孤儿对照。 - 客户端 10 条:形状换算、image 无图退回 none、越界夹取、拉取生效、 ★拉取不推送、推送 payload、未登录/500 → local-only、推失败 → pending、 ★壁纸只上传一次。 - 全量:server 10 包全绿、客户端 249 通过(含打包一致性判据 —— 它先红后绿, 因为前端改了必须重打安装包,这条护栏是先前特意留下的)。 ## 线上验证与交付 - jianf 设置 → 回包 saved=true;**gui-lab 读到自己那份默认值**(隔离生效); gui-lab 上传 67B PNG → 取回 sha256 一致、`has_image=true`;DELETE 后 404。 - 网关已重打(WebUI 内嵌)并部署;Electron 安装包已重打(AppImage + deb)。 遗留:鸿蒙端还没有外观功能(数据已在服务端,将来可直接读);本地缓存仍在(离线可用)。
476 lines
21 KiB
SQL
476 lines
21 KiB
SQL
-- AgentMail MVP Schema
|
||
-- PostgreSQL 14+
|
||
|
||
-- Users table(人类多用户;username 与 agents.agent_name 共用命名空间)
|
||
CREATE TABLE IF NOT EXISTS users (
|
||
user_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||
username VARCHAR(64) NOT NULL UNIQUE,
|
||
display_name VARCHAR(128) NOT NULL DEFAULT '',
|
||
password_hash VARCHAR(255) NOT NULL,
|
||
role VARCHAR(16) NOT NULL DEFAULT 'user',
|
||
status VARCHAR(16) NOT NULL DEFAULT 'active',
|
||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||
last_login TIMESTAMPTZ
|
||
);
|
||
|
||
CREATE TABLE IF NOT EXISTS user_sessions (
|
||
token VARCHAR(64) PRIMARY KEY,
|
||
user_id UUID NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
|
||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||
expires_at TIMESTAMPTZ NOT NULL,
|
||
user_agent VARCHAR(256) DEFAULT ''
|
||
);
|
||
|
||
CREATE INDEX IF NOT EXISTS idx_user_sessions_user ON user_sessions(user_id);
|
||
CREATE INDEX IF NOT EXISTS idx_user_sessions_exp ON user_sessions(expires_at);
|
||
|
||
-- 用户权限边界:可调用的 Agent 与可访问的工作区目录
|
||
-- allowed_agents:["deepseekharness","pi"],空数组 = 不限(继承系统默认)
|
||
-- allowed_paths :["/program","/home/x"],空数组 = 不限;按前缀匹配
|
||
-- agent_aliases :{"大龙":"deepseekharness","小派":"pi"},发信时自动解析真实名
|
||
ALTER TABLE users ADD COLUMN IF NOT EXISTS allowed_agents JSONB NOT NULL DEFAULT '[]';
|
||
ALTER TABLE users ADD COLUMN IF NOT EXISTS allowed_paths JSONB NOT NULL DEFAULT '[]';
|
||
ALTER TABLE users ADD COLUMN IF NOT EXISTS agent_aliases JSONB NOT NULL DEFAULT '{}';
|
||
|
||
-- Agents table
|
||
CREATE TABLE IF NOT EXISTS agents (
|
||
agent_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||
agent_name VARCHAR(64) NOT NULL UNIQUE,
|
||
secret VARCHAR(128) NOT NULL,
|
||
host_url VARCHAR(256) NOT NULL DEFAULT '',
|
||
workspaces JSONB NOT NULL DEFAULT '[]',
|
||
platform VARCHAR(32) NOT NULL DEFAULT 'pi',
|
||
status VARCHAR(16) NOT NULL DEFAULT 'offline',
|
||
max_rounds INT NOT NULL DEFAULT 10,
|
||
used_rounds INT NOT NULL DEFAULT 0,
|
||
last_seen TIMESTAMPTZ,
|
||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||
);
|
||
|
||
-- Sessions table
|
||
CREATE TABLE IF NOT EXISTS sessions (
|
||
session_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||
session_alias VARCHAR(128),
|
||
-- 这条会话所属的工作目录(见 init_sqlite.sql 里的设计说明)
|
||
workspace VARCHAR(512) NOT NULL DEFAULT '',
|
||
from_agent VARCHAR(64) NOT NULL,
|
||
subject VARCHAR(512) NOT NULL,
|
||
-- 接管的平台侧会话 id(见 init_sqlite.sql 的说明)
|
||
platform_id VARCHAR(256) NOT NULL DEFAULT '',
|
||
status VARCHAR(32) NOT NULL DEFAULT 'active',
|
||
owner_user_id UUID REFERENCES users(user_id),
|
||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||
);
|
||
|
||
CREATE INDEX IF NOT EXISTS idx_sessions_alias ON sessions(session_alias);
|
||
CREATE INDEX IF NOT EXISTS idx_sessions_status ON sessions(status);
|
||
|
||
-- 会话别名负责寻址(name@path.<alias>),必须全局唯一。
|
||
-- 部分唯一索引:未命名会话(NULL)不受约束,可以有任意多个。
|
||
CREATE UNIQUE INDEX IF NOT EXISTS idx_sessions_alias_uniq
|
||
ON sessions(session_alias) WHERE session_alias IS NOT NULL;
|
||
|
||
-- 已存在的库补列(必须先于依赖该列的索引)
|
||
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS owner_user_id UUID REFERENCES users(user_id);
|
||
|
||
-- 用户驳回过的改名提议。记下来才能让提示条不再反复弹同一个建议。
|
||
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS rename_dismissed TEXT;
|
||
|
||
-- 别名是谁定的:'platform'(Agent 平台自动同步,可被后续同步覆盖)
|
||
-- 或 'manual'(人显式指定,平台同步不得覆盖)。
|
||
-- 没有这个标记,平台的下一次 session.updated 会把人刚接受的名字冲掉。
|
||
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS alias_source TEXT NOT NULL DEFAULT 'platform';
|
||
|
||
-- 本次任务的往返预算(0 = 本会话不限,仅受 Agent 全局配额约束)。
|
||
--
|
||
-- 配额的真实语义是「这件事值得多少个来回」,那是任务的属性而不是 Agent 的属性:
|
||
-- 只有 agents.max_rounds 一个全局计数器时,两个并行任务会互相抢额度,
|
||
-- 且 used_rounds 单调递增,一旦跑满就得管理员手工重置才能再干活。
|
||
-- 因此预算下沉到会话,由人在写信时给、在对话页里随时调。
|
||
--
|
||
-- Agent 全局配额仍然生效(两者都要过):否则 Agent 自己 .new 开一串会话,
|
||
-- 每条都是全新预算,全局上限就形同虚设。
|
||
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS max_rounds INTEGER NOT NULL DEFAULT 0;
|
||
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS used_rounds INTEGER NOT NULL DEFAULT 0;
|
||
|
||
-- 会话级权限档位(plan / workspace / full)。
|
||
--
|
||
-- 旧库默认 'workspace' 而不是 'full':已在进行的会话大多是「在这个目录里干活」,
|
||
-- 给 workspace 与它们的实际形态一致。默认 full 则等于给所有历史会话追授全权,
|
||
-- 而「我忘了收紧」与「我确实需要全权」在数据上从此无法区分。
|
||
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS permission_mode TEXT NOT NULL DEFAULT 'workspace';
|
||
|
||
-- 接收平台实际做到的强制力(native / advisory),由插件心跳自报后落到会话上。
|
||
-- 旧库默认 'advisory':没自报过的插件,不能替它宣称「档位在这里是被强制的」。
|
||
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS permission_enforcement TEXT NOT NULL DEFAULT 'advisory';
|
||
|
||
-- Agent 自报的档位强制能力(native / advisory),随心跳更新。
|
||
-- 与 sessions.permission_enforcement 的区别:这里是平台当下的能力,
|
||
-- 那里是某条会话建立时的事实快照 —— 插件升级后能力会变,已结束的会话不该被改写。
|
||
ALTER TABLE agents ADD COLUMN IF NOT EXISTS mode_enforcement TEXT NOT NULL DEFAULT 'advisory';
|
||
|
||
CREATE INDEX IF NOT EXISTS idx_sessions_owner ON sessions(owner_user_id);
|
||
|
||
-- Mails table
|
||
CREATE TABLE IF NOT EXISTS mails (
|
||
mail_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||
session_id UUID NOT NULL REFERENCES sessions(session_id),
|
||
parent_mail_id UUID REFERENCES mails(mail_id),
|
||
|
||
from_name VARCHAR(64) NOT NULL,
|
||
from_workspace VARCHAR(128) DEFAULT '',
|
||
to_name VARCHAR(64) NOT NULL,
|
||
to_workspace VARCHAR(128) DEFAULT '',
|
||
|
||
subject VARCHAR(512) NOT NULL,
|
||
body TEXT NOT NULL,
|
||
|
||
-- 拄送列表:[{"name":"pi","path":"root","session":"new","raw":"pi@root.new"}]
|
||
cc_list JSONB NOT NULL DEFAULT '[]',
|
||
|
||
mail_type VARCHAR(32) NOT NULL DEFAULT 'normal',
|
||
permission_options JSONB,
|
||
permission_result VARCHAR(32),
|
||
|
||
status VARCHAR(16) NOT NULL DEFAULT 'unread',
|
||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||
|
||
hop_limit INT DEFAULT 5
|
||
);
|
||
|
||
CREATE INDEX IF NOT EXISTS idx_mails_session ON mails(session_id);
|
||
CREATE INDEX IF NOT EXISTS idx_mails_to ON mails(to_name, status);
|
||
CREATE INDEX IF NOT EXISTS idx_mails_parent ON mails(parent_mail_id);
|
||
|
||
-- 已存在的库补列(重复运行安全)
|
||
ALTER TABLE mails ADD COLUMN IF NOT EXISTS cc_list JSONB NOT NULL DEFAULT '[]';
|
||
|
||
-- default_rounds 是【派给这个 Agent 的新任务】默认有多少个来回。
|
||
-- 配额是任务的属性,真正的约束在 sessions.max_rounds 上;这里只提供默认值。
|
||
ALTER TABLE agents ADD COLUMN IF NOT EXISTS default_rounds INTEGER NOT NULL DEFAULT 20;
|
||
|
||
-- Agent 在正文里提议改会话别名(<!-- agentmail:rename-session … -->)。
|
||
-- 存在邮件上而非会话上:邮件是不可篡改的历史记录,「谁在哪一封里提了什么」应当留痕。
|
||
ALTER TABLE mails ADD COLUMN IF NOT EXISTS rename_alias TEXT;
|
||
ALTER TABLE mails ADD COLUMN IF NOT EXISTS rename_reason TEXT;
|
||
|
||
-- 抄送检索:cc_list @> '[{"name":"pi"}]' 走 GIN
|
||
CREATE INDEX IF NOT EXISTS idx_mails_cc_list ON mails USING GIN (cc_list jsonb_path_ops);
|
||
|
||
-- 历史数据规整:早期写入过首字母大写的键(Raw/Name/Path/Session),统一成小写
|
||
UPDATE mails
|
||
SET cc_list = (
|
||
SELECT COALESCE(jsonb_agg(
|
||
jsonb_strip_nulls(jsonb_build_object(
|
||
'name', COALESCE(e->>'name', e->>'Name'),
|
||
'path', COALESCE(e->>'path', e->>'Path'),
|
||
'session', COALESCE(e->>'session', e->>'Session'),
|
||
'raw', COALESCE(e->>'raw', e->>'Raw')
|
||
))
|
||
), '[]'::jsonb)
|
||
FROM jsonb_array_elements(cc_list) AS e
|
||
)
|
||
WHERE cc_list @? '$[*].Name';
|
||
|
||
-- Permission requests table
|
||
CREATE TABLE IF NOT EXISTS permission_requests (
|
||
request_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||
mail_id UUID NOT NULL REFERENCES mails(mail_id),
|
||
session_id UUID NOT NULL REFERENCES sessions(session_id),
|
||
agent_name VARCHAR(64) NOT NULL,
|
||
question TEXT NOT NULL,
|
||
options JSONB NOT NULL DEFAULT '["同意", "拒绝"]',
|
||
context TEXT DEFAULT '',
|
||
-- permission = 危险操作审批;question = Agent 主动补充询问。
|
||
kind VARCHAR(16) NOT NULL DEFAULT 'permission',
|
||
-- ask_user_question 的多选语义;普通权限审批恒为 false。
|
||
multi_select BOOLEAN NOT NULL DEFAULT FALSE,
|
||
result TEXT,
|
||
decided_at TIMESTAMPTZ,
|
||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||
);
|
||
|
||
CREATE INDEX IF NOT EXISTS idx_perm_agent ON permission_requests(agent_name, result);
|
||
CREATE INDEX IF NOT EXISTS idx_perm_pending ON permission_requests(result) WHERE result IS NULL;
|
||
|
||
-- 已部署库补齐主动询问元数据,并解除旧 result VARCHAR(32) 对长文本回答的限制。
|
||
ALTER TABLE permission_requests ADD COLUMN IF NOT EXISTS kind VARCHAR(16) NOT NULL DEFAULT 'permission';
|
||
ALTER TABLE permission_requests ADD COLUMN IF NOT EXISTS multi_select BOOLEAN NOT NULL DEFAULT FALSE;
|
||
ALTER TABLE permission_requests ALTER COLUMN result TYPE TEXT;
|
||
|
||
-- 历史邮件里的字面量 'human' 迁移到默认管理员账号
|
||
-- (管理员由 Go 侧 EnsureAdminUser 首启创建,此处只做数据重写)
|
||
DO $$
|
||
DECLARE
|
||
admin_name TEXT;
|
||
BEGIN
|
||
SELECT username INTO admin_name
|
||
FROM users WHERE role = 'admin' AND status = 'active'
|
||
ORDER BY created_at ASC LIMIT 1;
|
||
|
||
IF admin_name IS NULL THEN
|
||
RETURN; -- 还没有管理员,下次迁移再试
|
||
END IF;
|
||
|
||
UPDATE mails SET from_name = admin_name WHERE from_name = 'human';
|
||
UPDATE mails SET to_name = admin_name WHERE to_name = 'human';
|
||
UPDATE sessions SET from_agent = admin_name WHERE from_agent = 'human';
|
||
|
||
-- 拄送列表里的 human 一并重写
|
||
UPDATE mails
|
||
SET cc_list = (
|
||
SELECT COALESCE(jsonb_agg(
|
||
CASE WHEN e->>'name' = 'human'
|
||
THEN jsonb_set(
|
||
jsonb_set(e, '{name}', to_jsonb(admin_name)),
|
||
'{raw}',
|
||
to_jsonb(admin_name || '@' || COALESCE(e->>'path','') ||
|
||
CASE WHEN COALESCE(e->>'session','') = '' THEN ''
|
||
ELSE '.' || (e->>'session') END))
|
||
ELSE e END
|
||
), '[]'::jsonb)
|
||
FROM jsonb_array_elements(cc_list) AS e
|
||
)
|
||
WHERE cc_list @> '[{"name":"human"}]';
|
||
|
||
-- 人类发起的会话补上 owner
|
||
UPDATE sessions s
|
||
SET owner_user_id = u.user_id
|
||
FROM users u
|
||
WHERE u.username = admin_name
|
||
AND s.owner_user_id IS NULL
|
||
AND s.from_agent = admin_name;
|
||
END $$;
|
||
|
||
-- ---------- 密钥认证体系 ----------
|
||
--
|
||
-- 两类密钥,共享一个全局唯一的 token 命名空间(验证时先查 agent_keys 再查 user_keys):
|
||
-- agent_keys:管理员签发,用于 Agent 注册/心跳/SSE
|
||
-- user_keys :用户自助签发,仅用于 /me/* 人类邮箱接口,不可注册 Agent
|
||
--
|
||
-- key_type:
|
||
-- permanent — 永不过期,可重复使用
|
||
-- one_time — 首次验证后写 used_at,再用即拒
|
||
-- timed — expires_at 之后失效
|
||
|
||
CREATE TABLE IF NOT EXISTS agent_keys (
|
||
key_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||
key_token VARCHAR(128) NOT NULL UNIQUE,
|
||
agent_name VARCHAR(64), -- NULL = 待绑定
|
||
key_type VARCHAR(16) NOT NULL DEFAULT 'permanent',
|
||
label VARCHAR(128) NOT NULL DEFAULT '',
|
||
expires_at TIMESTAMPTZ,
|
||
used_at TIMESTAMPTZ,
|
||
created_by UUID REFERENCES users(user_id),
|
||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||
);
|
||
|
||
CREATE INDEX IF NOT EXISTS idx_agent_keys_token ON agent_keys(key_token);
|
||
CREATE INDEX IF NOT EXISTS idx_agent_keys_agent ON agent_keys(agent_name);
|
||
|
||
CREATE TABLE IF NOT EXISTS user_keys (
|
||
key_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||
key_token VARCHAR(128) NOT NULL UNIQUE,
|
||
user_id UUID NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
|
||
label VARCHAR(128) NOT NULL DEFAULT '',
|
||
key_type VARCHAR(16) NOT NULL DEFAULT 'permanent',
|
||
expires_at TIMESTAMPTZ,
|
||
used_at TIMESTAMPTZ,
|
||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||
);
|
||
|
||
CREATE INDEX IF NOT EXISTS idx_user_keys_user ON user_keys(user_id);
|
||
CREATE INDEX IF NOT EXISTS idx_user_keys_token ON user_keys(key_token);
|
||
|
||
-- ---------- 附件 ----------
|
||
--
|
||
-- 文件内容存磁盘(内容寻址:路径由 sha256 派生),数据库只存元数据。
|
||
--
|
||
-- mail_id 为 NULL 表示「已上传但还没挂到邮件上」的待用附件:
|
||
-- 上传与发信是两步(Agent 工具是 JSON 接口,没法在发信时带 multipart),
|
||
-- 中间态必须允许存在;超时未挂载的由 GC 清掉。
|
||
|
||
-- 插件自动转发的邮件登记表。
|
||
--
|
||
-- **配额约束的是模型的自主发信,不是 harness 的转发**(基本原则):
|
||
-- 配额存在的意义是防止 Agent 无限自我循环。而「把平台原生的权限询问转给人」
|
||
-- 与「把本轮的最终总结转给人」都是插件代劳的搬运,不是模型自己决定要发的信 ——
|
||
-- 对它们收费会导致配额用尽时 Agent 连交代都做不了。
|
||
--
|
||
-- relay_key 是上游那条消息的稳定标识(opencode 的 permission id / assistant message id)。
|
||
-- 唯一约束把「同一条上游消息只转一次」变成一条 INSERT 的成败:
|
||
-- * 插件重试、SSE 重连后重放都不会产生第二封
|
||
-- * 也顺带给免配额通道加了结构性上限 —— 想多转就得拿出不同的上游消息 id
|
||
CREATE TABLE IF NOT EXISTS relayed_mails (
|
||
agent_name VARCHAR(64) NOT NULL,
|
||
relay_key VARCHAR(160) NOT NULL,
|
||
mail_id UUID REFERENCES mails(mail_id),
|
||
kind VARCHAR(32) NOT NULL DEFAULT '',
|
||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||
PRIMARY KEY (agent_name, relay_key)
|
||
);
|
||
|
||
-- 人类决策后要按 mail_id 反查上游 permission id
|
||
CREATE INDEX IF NOT EXISTS idx_relayed_mail ON relayed_mails(mail_id);
|
||
|
||
CREATE TABLE IF NOT EXISTS attachments (
|
||
attachment_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||
mail_id UUID REFERENCES mails(mail_id) ON DELETE CASCADE,
|
||
uploader VARCHAR(64) NOT NULL,
|
||
filename VARCHAR(512) NOT NULL,
|
||
content_type VARCHAR(128) NOT NULL DEFAULT 'application/octet-stream',
|
||
size_bytes BIGINT NOT NULL,
|
||
sha256 CHAR(64) NOT NULL,
|
||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||
);
|
||
|
||
CREATE INDEX IF NOT EXISTS idx_attachments_mail ON attachments(mail_id);
|
||
CREATE INDEX IF NOT EXISTS idx_attachments_sha ON attachments(sha256);
|
||
CREATE INDEX IF NOT EXISTS idx_attachments_orphan ON attachments(created_at) WHERE mail_id IS NULL;
|
||
|
||
-- 速率限制(登录失败 + 新建会话)
|
||
CREATE TABLE IF NOT EXISTS rate_limits (
|
||
bucket TEXT NOT NULL,
|
||
ts TIMESTAMPTZ NOT NULL,
|
||
expired BOOLEAN NOT NULL DEFAULT FALSE
|
||
);
|
||
CREATE INDEX IF NOT EXISTS idx_rate_limits_bucket ON rate_limits(bucket, ts);
|
||
|
||
-- ---------- 邮件场景下的可用模型(见 init_sqlite.sql 里的设计说明) ----------
|
||
CREATE TABLE IF NOT EXISTS agent_model_catalog (
|
||
agent_name VARCHAR(128) NOT NULL,
|
||
provider VARCHAR(128) NOT NULL,
|
||
model VARCHAR(256) NOT NULL,
|
||
display_name VARCHAR(256) NOT NULL DEFAULT '',
|
||
reported_at TIMESTAMPTZ DEFAULT NOW(),
|
||
PRIMARY KEY (agent_name, provider, model)
|
||
);
|
||
|
||
CREATE TABLE IF NOT EXISTS agent_allowed_models (
|
||
agent_name VARCHAR(128) NOT NULL,
|
||
provider VARCHAR(128) NOT NULL,
|
||
model VARCHAR(256) NOT NULL,
|
||
rank INTEGER NOT NULL DEFAULT 0,
|
||
PRIMARY KEY (agent_name, provider, model)
|
||
);
|
||
|
||
CREATE INDEX IF NOT EXISTS idx_agent_allowed_rank ON agent_allowed_models(agent_name, rank);
|
||
|
||
-- ---------- 平台会话镜像(见 init_sqlite.sql 里的设计说明) ----------
|
||
CREATE TABLE IF NOT EXISTS agent_platform_sessions (
|
||
agent_name VARCHAR(128) NOT NULL,
|
||
platform_id VARCHAR(256) NOT NULL,
|
||
workspace VARCHAR(512) NOT NULL DEFAULT '',
|
||
slug VARCHAR(256) NOT NULL DEFAULT '',
|
||
title VARCHAR(512) NOT NULL DEFAULT '',
|
||
mail_driven BOOLEAN NOT NULL DEFAULT FALSE,
|
||
updated_at TIMESTAMPTZ,
|
||
reported_at TIMESTAMPTZ DEFAULT NOW(),
|
||
PRIMARY KEY (agent_name, platform_id)
|
||
);
|
||
|
||
CREATE INDEX IF NOT EXISTS idx_platform_sessions_ws
|
||
ON agent_platform_sessions(agent_name, workspace);
|
||
|
||
-- ---------- 日历(见 init_sqlite.sql 里的设计说明) ----------
|
||
--
|
||
-- 三层分离:事件是日历实体,提醒是触发器,邮件是投递通道。
|
||
-- 这份 PG schema 曾经整块缺失 —— 后果是 DATABASE_URL 一旦非空,
|
||
-- 所有 /calendar/* 端点在 relation does not exist 上 500,
|
||
-- 而 SQLite 下一切正常,于是问题只在切外部库时才暴露。
|
||
CREATE TABLE IF NOT EXISTS calendar_events (
|
||
event_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||
title VARCHAR(512) NOT NULL,
|
||
description TEXT NOT NULL DEFAULT '',
|
||
|
||
-- 提醒邮件正文模板,支持 {title} {time} {description}
|
||
reminder_text TEXT NOT NULL DEFAULT '',
|
||
|
||
-- 收件方:agent_name 是兜底,to_address 是权威(完整三维寻址)
|
||
agent_name VARCHAR(128) NOT NULL DEFAULT '',
|
||
to_address VARCHAR(512) NOT NULL DEFAULT '',
|
||
|
||
event_time TIMESTAMPTZ NOT NULL,
|
||
remind_before INTEGER NOT NULL DEFAULT 0,
|
||
recurrence VARCHAR(32) NOT NULL DEFAULT 'none',
|
||
recurrence_end TIMESTAMPTZ,
|
||
|
||
-- 收件人列表与投递方式(见 init_sqlite.sql 的说明)
|
||
recipients JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||
delivery_mode VARCHAR(32) NOT NULL DEFAULT 'separate',
|
||
|
||
status VARCHAR(32) NOT NULL DEFAULT 'active',
|
||
last_fired_at TIMESTAMPTZ,
|
||
-- 已触发的 occurrence(= 当时的 event_time)。见 init_sqlite.sql 的说明。
|
||
fired_for TIMESTAMPTZ,
|
||
-- 权限档位(plan / workspace / full)。见 init_sqlite.sql 的说明。
|
||
permission_mode TEXT NOT NULL DEFAULT 'workspace',
|
||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||
created_by VARCHAR(128) NOT NULL DEFAULT ''
|
||
);
|
||
|
||
CREATE INDEX IF NOT EXISTS idx_calendar_next_fire
|
||
ON calendar_events(status, event_time) WHERE status = 'active';
|
||
|
||
CREATE INDEX IF NOT EXISTS idx_calendar_time_range
|
||
ON calendar_events(event_time, status);
|
||
|
||
CREATE TABLE IF NOT EXISTS calendar_attachments (
|
||
attachment_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||
event_id UUID NOT NULL REFERENCES calendar_events(event_id) ON DELETE CASCADE,
|
||
filename VARCHAR(512) NOT NULL,
|
||
sha256 VARCHAR(64) NOT NULL DEFAULT '',
|
||
size_bytes BIGINT NOT NULL DEFAULT 0,
|
||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||
);
|
||
|
||
CREATE INDEX IF NOT EXISTS idx_calendar_att_event
|
||
ON calendar_attachments(event_id);
|
||
|
||
-- 接管平台会话时按 platform_id 反查本侧会话。
|
||
--
|
||
-- 先 ALTER 再建索引:CREATE TABLE IF NOT EXISTS 不会给**已存在**的表补列,
|
||
-- 而这个脚本在已部署的库上也要能跑。PG 支持 ADD COLUMN IF NOT EXISTS,
|
||
-- 所以这里不需要像 SQLite 那样绕到代码里去(见 migrate.go 的 sqliteAddIndexes)。
|
||
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS platform_id VARCHAR(256) NOT NULL DEFAULT '';
|
||
|
||
CREATE INDEX IF NOT EXISTS idx_sessions_platform
|
||
ON sessions(platform_id) WHERE platform_id <> '';
|
||
|
||
-- ─── 按人记录的已读状态(mail_reads)─────────────────────────────────
|
||
--
|
||
-- 与 init_sqlite.sql 的同一段语义一致:未读是**每个读者各自的状态**,
|
||
-- 而不是邮件行上的一个全局列(详见那边的注释:dsh 那句"收件箱列表未展示它")。
|
||
CREATE TABLE IF NOT EXISTS mail_reads (
|
||
mail_id TEXT NOT NULL REFERENCES mails(mail_id),
|
||
reader_name TEXT NOT NULL,
|
||
read_at TIMESTAMPTZ DEFAULT NOW(),
|
||
PRIMARY KEY (mail_id, reader_name)
|
||
);
|
||
|
||
CREATE INDEX IF NOT EXISTS idx_mail_reads_reader ON mail_reads(reader_name);
|
||
|
||
-- 一次性迁移标记(见 sqlite 侧注释)
|
||
CREATE TABLE IF NOT EXISTS app_meta (
|
||
key TEXT PRIMARY KEY,
|
||
value TEXT NOT NULL DEFAULT ''
|
||
);
|
||
|
||
-- 用户外观(主题 + 壁纸):与 init_sqlite.sql 同一张表、同一份语义
|
||
-- (为什么放服务端、为什么用列而不是 JSON,见那边的注释)。
|
||
CREATE TABLE IF NOT EXISTS user_appearance (
|
||
user_id TEXT NOT NULL,
|
||
theme TEXT NOT NULL DEFAULT 'system',
|
||
bg_kind TEXT NOT NULL DEFAULT 'none',
|
||
bg_preset_id TEXT NOT NULL DEFAULT '',
|
||
bg_dim INTEGER NOT NULL DEFAULT 12,
|
||
bg_blur INTEGER NOT NULL DEFAULT 4,
|
||
image_sha256 TEXT NOT NULL DEFAULT '',
|
||
image_type TEXT NOT NULL DEFAULT '',
|
||
image_bytes INTEGER NOT NULL DEFAULT 0,
|
||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||
PRIMARY KEY (user_id)
|
||
);
|