用户裁定:
· 「可以在服务器集成一言与签名,同时 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 也会被注释里的反引号/连续减号破坏。
134 lines
4.3 KiB
Go
134 lines
4.3 KiB
Go
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
|
||
}
|