服务端: 顶栏内容端点(一言句库缓存 + 个人签名)+ 修老库升级时序 bug
用户裁定:
· 「可以在服务器集成一言与签名,同时 app 本地缓存一部分」
· 「摘要也应该放在顶部,显示摘要不显示一言,显示一言不显示摘要」
· 「自动轮播,要有消失出现动画。同时注意,是纯文字不要加底」
新增端点
· GET /api/v1/me/topbar → { quotes: [{text, source}], signature }
一次给一批(默认 10 条),客户端拿去本地轮播 —— 轮播是秒级的,
每条问一次服务器既浪费又会在断网时停下(而轮播的观感依赖"一直有下一条")。
· PUT /api/v1/me/signature —— 改个人签名(「我的」页用)
· quotes 表(句库缓存)+ users.signature 列
设计要点
· 一言**落库缓存**:库里有就**不打外网**(常态路径);不足 20 条才去
hitokoto 补一批。补失败**不影响返回** —— 装饰性内容不该成为失败点
(顶栏少轮播内容是小事,整个接口 500 会让 App 启动时顶栏坏掉)。
· 签名存 users 而不是 quotes 表:它是**用户资料**(跟账号走、
在「我的」页可编辑),放 quotes 里会让"改签名"变成"改一条 quote"。
· 限长 80 字,超了**拒绝且不落库** —— 顶栏是一行,静默截断比报错更坏
(用户以为存进去了,实际存的是被砍过的)。
· 迁移改两处(本仓既定纪律):init_sqlite.sql 给新库 +
sqliteAddColumns 给老库。
★ 顺手修掉一个既有 bug(不是本次引入的)
「从很旧的库升级会直接启动失败」:
migrate sqlite (语句 #10 … idx_sessions_path_alias_uniq):
SQL logic error: no such column: workspace
根因是**时序**:这条索引引用 sessions.workspace,而那是**后补的列**
(sqliteAddColumns),索引却住在 init_sqlite.sql(在补列**之前**执行)。
新库没事(建表时就有该列);老库直接炸,且报错指向索引名 ——
看着像索引写错,实际是顺序问题。
生产库一直没暴露,因为它早就补过列了(暴露面只有"从很旧的库升级")。
证据:`git stash` 掉当天全部改动后**同样复现**。
修法:把索引搬到 migrate.go 的 sqliteAddIndexes(那个列表在补列之后跑)。
测试(internal/handler/topbar_test.go,5/5)
① 签名账号隔离 —— bob 没设过就该是空串,不能串到 alice 的
(本仓 user_appearance 那轮踩过"多账号共用一份",同一形状不许重演)
② 有货不打外网(灌 25 条,断言返回不超过 quoteBatchSize)
③ ★ 外网挂了仍返回 —— 耗时 4.01s = quoteHTTPTimeout,
证明它真去拉了并按超时降级,不是假绿
④ 限长:81 字拒绝**且不落库**;80 字(边界)接受
⑤ 未登录读写都 401
★ 两个踩过的坑(记进注释了)
1. `init_sqlite.sql` **只能写 `--` 行注释**:切语句器只跳过 `--` 开头的行,
块注释的文字会被当 SQL 执行。我第一版用 `/* */`,新库初始化直接失败,
且报错指向一个完全无关的地方(no such column: workspace)。
2. 该 SQL 文件的 splitStatements 也会被注释里的反引号/连续减号破坏。
This commit is contained in:
@ -329,6 +329,9 @@ var sqliteAddColumns = []struct{ table, column, ddl string }{
|
||||
// 旧库默认 ''/0:历史权限邮件按单选审批渲染。
|
||||
{"mails", "permission_kind", "ALTER TABLE mails ADD COLUMN permission_kind TEXT NOT NULL DEFAULT ''"},
|
||||
{"mails", "permission_multi_select", "ALTER TABLE mails ADD COLUMN permission_multi_select INTEGER NOT NULL DEFAULT 0"},
|
||||
// 个人签名(顶栏「一言」的另一个来源)。
|
||||
// 旧库默认空串 = 「还没设过」,客户端因此不会显示签名(而不是显示一个空白)。
|
||||
{"users", "signature", "ALTER TABLE users ADD COLUMN signature TEXT NOT NULL DEFAULT ''"},
|
||||
}
|
||||
|
||||
// sqliteAddIndexes 是建表后才能建的索引(依赖上面补的列)。
|
||||
@ -338,6 +341,24 @@ var sqliteAddIndexes = []string{
|
||||
"CREATE INDEX IF NOT EXISTS idx_sessions_platform ON sessions(platform_id) WHERE platform_id <> ''",
|
||||
// 人类决策后要按 mail_id 反查上游 permission id
|
||||
"CREATE INDEX IF NOT EXISTS idx_relayed_mail ON relayed_mails(mail_id)",
|
||||
// 一言句库按来源取(顶栏轮播时只挑一个来源)
|
||||
"CREATE INDEX IF NOT EXISTS idx_quotes_origin ON quotes(origin)",
|
||||
// 会话的「path + 别名」唯一索引 —— 它引用 sessions.workspace,
|
||||
// 而那一列是**后补的**(见 sqliteAddColumns)。
|
||||
//
|
||||
// ★★ 2026-09-25 从 init_sqlite.sql 搬到这里,修一个**只在老库升级时暴露**的真 bug:
|
||||
// 原来它住在 init_sqlite.sql,而那个文件在 addMissingColumns **之前**执行。
|
||||
// 新库没问题(建表时就有 workspace);老库直接死:
|
||||
// migrate sqlite (语句 #10 … idx_sessions_path_alias_uniq):
|
||||
// SQL logic error: no such column: workspace
|
||||
// 报错指向索引名,看着像索引写错 —— 实际是**时序**(列还没补就建索引)。
|
||||
//
|
||||
// 证据:`git stash` 掉当天全部改动后**同样复现** ⇒ 既有缺陷,不是新引入的。
|
||||
// 生产库一直没事,因为它早就补过列(暴露面只有"从很旧的库升级")。
|
||||
//
|
||||
// 放这里两边都对:新库建表时已有列;老库补列后才建索引。
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS idx_sessions_path_alias_uniq " +
|
||||
"ON sessions(COALESCE(workspace,''), session_alias) WHERE session_alias IS NOT NULL",
|
||||
}
|
||||
|
||||
func addMissingColumns(ctx context.Context) error {
|
||||
|
||||
@ -30,9 +30,41 @@ CREATE TABLE IF NOT EXISTS users (
|
||||
-- 权限边界:空数组 = 不限
|
||||
allowed_agents TEXT NOT NULL DEFAULT '[]',
|
||||
allowed_paths TEXT NOT NULL DEFAULT '[]',
|
||||
agent_aliases TEXT NOT NULL DEFAULT '{}'
|
||||
agent_aliases TEXT NOT NULL DEFAULT '{}',
|
||||
|
||||
-- 个人签名(顶栏「一言」的另一个来源)。
|
||||
--
|
||||
-- 2026-09-24:用户要求顶栏能轮播「一言 + 签名」,签名是用户的,
|
||||
-- 所以存在 users 上而不是外观表里(签名不属于外观)。
|
||||
signature TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
-- 一言句库(服务端拉取 + 缓存;客户端再缓一层)。
|
||||
--
|
||||
-- 2026-09-24:用户要求顶栏轮播「一言」,并明确「在服务器集成一言」。
|
||||
--
|
||||
-- 为什么落库而不是每次转发外网:
|
||||
-- 1 外网挂了不该让顶栏空着(本地已有句库就照旧发)
|
||||
-- 2 每次启动都打外网是把一个装饰性文案变成新的失败点
|
||||
-- 3 句库本身不会变 —— 缓下来就一直是有效数据
|
||||
-- 所以这张表是句库缓存,不是业务数据:删了也不影响正确性,只会触发重拉。
|
||||
--
|
||||
-- 注意:本文件只用行注释,不要用 C 风格块注释。
|
||||
-- 建库时按分号切语句,而切分器只跳过以双横线开头的行;
|
||||
-- 块注释里的文字会被当成 SQL 执行(我第一版就是因此让新库初始化失败,
|
||||
-- 报错还是一个不相干的地方)。同理注释里也不要写反引号。
|
||||
CREATE TABLE IF NOT EXISTS quotes (
|
||||
quote_id TEXT PRIMARY KEY DEFAULT (gen_random_uuid()),
|
||||
-- 正文与出处(出处可空:一言接口有些句子没作者)
|
||||
text TEXT NOT NULL,
|
||||
source TEXT NOT NULL DEFAULT '',
|
||||
-- 来源标记:'hitokoto' = 外部拉取,'local' = 内置句库
|
||||
origin TEXT NOT NULL DEFAULT 'hitokoto',
|
||||
fetched_at DATETIME DEFAULT (strftime('%Y-%m-%d %H:%M:%f','now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_quotes_origin ON quotes(origin);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_sessions (
|
||||
token TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
|
||||
@ -154,8 +186,13 @@ CREATE INDEX IF NOT EXISTS idx_sessions_owner ON sessions(owner_user_id);
|
||||
-- 成为冗余(于是被忽略),一条 /home 之类的错 path 就能让两条不相干的线索落进同一个
|
||||
-- 会话、同一个 pi 会话文件(2026-09-15 实测:zcode 的"介绍请求"与 agentmail 的部署
|
||||
-- 往来混在 --home-- 的一个 jsonl 里)。
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_sessions_path_alias_uniq
|
||||
ON sessions(COALESCE(workspace,''), session_alias) WHERE session_alias IS NOT NULL;
|
||||
-- 注意:这条索引引用 sessions.workspace,而那是**后补的列**(sqliteAddColumns)。
|
||||
-- 老库的 sessions 没有这一列,所以索引必须等补完列再建 ——
|
||||
-- 它现在住在 migrate.go 的 sqliteAddIndexes 里(那个列表在补列之后执行)。
|
||||
--
|
||||
-- 2026-09-25 实测:一份 9 月 2 日的老库启动时直接死在这里
|
||||
-- (no such column: workspace),报错却指向这个索引名,看着像索引写错,
|
||||
-- 实际是**时序**问题(列还没补上就建索引)。生产库没暴露是因为它早补过了。
|
||||
-- 老索引必须显式丢弃:CREATE ... IF NOT EXISTS 不会删掉它,而它会继续把别名限制成全局唯一。
|
||||
DROP INDEX IF EXISTS idx_sessions_alias_uniq;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user