服务端: 顶栏内容端点(一言句库缓存 + 个人签名)+ 修老库升级时序 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:
2026-09-25 16:29:19 +08:00
parent e54dc39f8f
commit 31939f2b10
6 changed files with 595 additions and 3 deletions

View File

@ -0,0 +1,133 @@
package repo
import (
"context"
"database/sql"
"github.com/agentmail/gateway/internal/db"
)
/*
顶栏内容(一言句库 + 个人签名)的读写。
2026-09-24:用户要求桌面端顶栏轮播「摘要 ↔ 一言/签名」,并明确
「在服务器集成一言与签名,同时 app 本地缓存一部分」。
# 为什么一言落库(而不是每次请求都转发外网)
① 外网挂了不该让顶栏空着 —— 本地已有句库就照旧发;
② 每次启动打外网是把**一个装饰性文案变成新的失败点**;
③ 句库本身不会变 —— 缓下来就一直是有效数据。
所以 `quotes` 表是**句库缓存**,不是业务数据:删了也不影响正确性,
只会触发一次重拉。这与 `user_appearance`(真业务数据)的定位不同。
# 签名为什么在 users 上而不在这里
签名是**用户的个人资料**(像 QQ 签名),不是"顶栏内容"。
它跟着用户走、在「我的」页可编辑 —— 放 users 表才与"账号级资料"这个语义一致。
(放 quote 表里会让"改签名"变成"改一条 quote 记录",语义就错了。)
*/
// Quote 是一条一言(正文 + 出处)。
type Quote struct {
Text string `json:"text"`
Source string `json:"source"`
}
// CountQuotes 返回句库里现有多少条(用来决定要不要去外网补)。
func CountQuotes(ctx context.Context) (int, error) {
var n int
err := db.DB.QueryRowContext(ctx, `SELECT COUNT(*) FROM quotes`).Scan(&n)
return n, err
}
// InsertQuotes 批量写入一言(已存在的正文跳过 —— 见下面的注释)。
//
// ★ 用 `INSERT OR IGNORE` + 正文唯一:外网接口会**重复返回**同一句话,
// 那在句库里就是重复行。去重的判据取**正文**而不是外网 id ——
// 因为 id 是人家给的、我们只存正文与出处;正文相同就是同一句话。
//
// ★ 不建唯一索引而是先查后插:SQLite 与 PG 的 `INSERT OR IGNORE` 语法不同
// (PG 要 `ON CONFLICT DO NOTHING`),而本仓两套方言共用同一份 Go 代码。
// 所以走"先查正文是否存在、不存在才插"这种两边都通的写法。
// 量很小(一次十几条),不值得为它引一套方言分支。
func InsertQuotes(ctx context.Context, quotes []Quote, origin string) (int, error) {
if len(quotes) == 0 {
return 0, nil
}
inserted := 0
for _, q := range quotes {
if q.Text == "" {
continue
}
var exists int
err := db.DB.QueryRowContext(ctx, `SELECT COUNT(*) FROM quotes WHERE text = ?`, q.Text).Scan(&exists)
if err != nil {
return inserted, err
}
if exists > 0 {
continue
}
_, err = db.DB.ExecContext(ctx,
`INSERT INTO quotes (text, source, origin) VALUES (?, ?, ?)`,
q.Text, q.Source, origin)
if err != nil {
return inserted, err
}
inserted++
}
return inserted, nil
}
// RandomQuotes 随机取 limit 条一言。
//
// ★ `ORDER BY RANDOM()` 在 SQLite 与 PG 上**都能跑**(PG 是 `random()`,
//
// 但 SQLite 把 `RANDOM()` 当函数名、大小写不敏感 ⇒ 同一个写法两边通)。
// 句库规模是几百条量级,全表随机排序的代价可以忽略;
// 真到了十万条再谈"先取随机 id 再查"。
func RandomQuotes(ctx context.Context, limit int) ([]Quote, error) {
if limit <= 0 {
limit = 10
}
rows, err := db.DB.QueryContext(ctx,
`SELECT text, source FROM quotes ORDER BY RANDOM() LIMIT ?`, limit)
if err != nil {
return nil, err
}
defer rows.Close()
out := []Quote{}
for rows.Next() {
var q Quote
if err := rows.Scan(&q.Text, &q.Source); err != nil {
return nil, err
}
out = append(out, q)
}
return out, rows.Err()
}
// GetSignature 读用户的个人签名。
//
// 用户不存在时返回空串(不是错误):顶栏拿不到签名就只是不显示它,
// 不该让整个顶栏接口失败。
func GetSignature(ctx context.Context, username string) (string, error) {
var sig string
err := db.DB.QueryRowContext(ctx, `SELECT signature FROM users WHERE username = ?`, username).Scan(&sig)
if err == sql.ErrNoRows {
return "", nil
}
if err != nil {
return "", err
}
return sig, nil
}
// SetSignature 写用户的个人签名。
func SetSignature(ctx context.Context, username, signature string) error {
_, err := db.DB.ExecContext(ctx,
`UPDATE users SET signature = ? WHERE username = ?`, signature, username)
return err
}