Files
MailUI4Agents/server/internal/repo/quotes.go
JianFeeeee 31939f2b10 服务端: 顶栏内容端点(一言句库缓存 + 个人签名)+ 修老库升级时序 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 也会被注释里的反引号/连续减号破坏。
2026-09-25 16:29:19 +08:00

134 lines
4.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}