服务端: 顶栏内容端点(一言句库缓存 + 个人签名)+ 修老库升级时序 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:
197
server/internal/handler/topbar.go
Normal file
197
server/internal/handler/topbar.go
Normal file
@ -0,0 +1,197 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/agentmail/gateway/internal/middleware"
|
||||
"github.com/agentmail/gateway/internal/repo"
|
||||
)
|
||||
|
||||
/*
|
||||
顶栏内容(一言 + 签名)—— /api/v1/me/topbar
|
||||
|
||||
2026-09-24 用户的裁定:
|
||||
· 「可以在服务器集成一言与签名,同时 app 本地缓存一部分」
|
||||
· 「摘要也应该放在顶部,显示摘要不显示一言,显示一言不显示摘要」
|
||||
· 「自动轮播,要有消失出现动画。同时注意,是纯文字不要加底」
|
||||
|
||||
这个端点一次给出客户端轮播所需的两样东西:
|
||||
|
||||
{ "quotes": [{text, source}, …], "signature": "…" }
|
||||
|
||||
# 为什么一次给一批(而不是每次请求给一条)
|
||||
|
||||
客户端要**自动轮播**,而轮播是**秒级**的(十秒换一条)。若每条都问一次服务器:
|
||||
① 一屏轮播就是十几个请求,纯属浪费;② 网断的那一刻顶栏就停了 ——
|
||||
而轮播的观感依赖"一直有下一条"。
|
||||
所以这里给一批(默认 10 条),客户端拿去本地轮播;离线也照样转。
|
||||
|
||||
# 一言是外部拉取 + 落库缓存(不是每次转发)
|
||||
|
||||
`/quotes` 的语义:
|
||||
· 库里有 → 直接随机取几条返回(**不打外网**,这是常态路径);
|
||||
· 库里不足 `quoteSeedThreshold` 条 → **顺带**去 hitokoto 拉一批落库,
|
||||
再返回。拉失败**不影响本次返回**(有几个发几个;一个都没有才返回空数组)。
|
||||
★ 这条是"装饰性内容不该成为失败点"的落实:外网不通时顶栏只是少了一言,
|
||||
而不会整个接口 500。
|
||||
|
||||
# 为什么签名不在这里存
|
||||
|
||||
签名是**用户个人资料**(像 QQ 签名),住在 `users.signature`,
|
||||
编辑入口在「我的」页(与显示名同类)。本端点只**读**它 ——
|
||||
读写混在一个 handler 里会让"顶栏"这个语义变得含糊。
|
||||
*/
|
||||
|
||||
const (
|
||||
// 一次给客户端几条(够轮播一两分钟,不必频繁回源)
|
||||
quoteBatchSize = 10
|
||||
// 库里少于这个数就去外网补一批
|
||||
quoteSeedThreshold = 20
|
||||
// 一次从外网拉几条
|
||||
quoteFetchBatch = 15
|
||||
// 外网超时:这是**装饰性内容**,不能让它拖住顶栏
|
||||
quoteHTTPTimeout = 4 * time.Second
|
||||
)
|
||||
|
||||
// 一言接口(hitokoto)。`max_length` 限制长度 —— 顶栏是一行文字,
|
||||
// 太长的句子会被截断,不如一开始就不要。
|
||||
const hitokotoURL = "https://v1.hitokoto.cn/?encode=json&max_length=30"
|
||||
|
||||
// GET /api/v1/me/topbar
|
||||
func GetTopbar(w http.ResponseWriter, r *http.Request) {
|
||||
user := middleware.GetUser(r)
|
||||
if user == nil {
|
||||
Error(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
|
||||
sig, err := repo.GetSignature(ctx, user.Username)
|
||||
if err != nil {
|
||||
/* 读不到签名不致命:顶栏只是少那一半 */
|
||||
sig = ""
|
||||
}
|
||||
|
||||
quotes, err := ensureQuotes(ctx)
|
||||
if err != nil {
|
||||
/* 句库出错也不致命(同上)。返回空数组而不是 500 —— */
|
||||
quotes = []repo.Quote{}
|
||||
}
|
||||
|
||||
JSON(w, http.StatusOK, map[string]any{
|
||||
"quotes": quotes,
|
||||
"signature": sig,
|
||||
})
|
||||
}
|
||||
|
||||
// PUT /api/v1/me/signature —— 改个人签名(「我的」页用)
|
||||
func PutSignature(w http.ResponseWriter, r *http.Request) {
|
||||
user := middleware.GetUser(r)
|
||||
if user == nil {
|
||||
Error(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Signature string `json:"signature"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
/*
|
||||
* 限长 80 字:顶栏是一行,超了必然被截。
|
||||
* 在这里拒绝而不是让客户端截 —— 服务端拒绝能告诉用户"太长了",
|
||||
* 而静默截断会让人以为存进去了。
|
||||
*/
|
||||
sig := strings.TrimSpace(body.Signature)
|
||||
if len([]rune(sig)) > 80 {
|
||||
Error(w, http.StatusBadRequest, "signature too long (max 80)")
|
||||
return
|
||||
}
|
||||
if err := repo.SetSignature(r.Context(), user.Username, sig); err != nil {
|
||||
Error(w, http.StatusInternalServerError, "Failed to save signature")
|
||||
return
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]any{"signature": sig})
|
||||
}
|
||||
|
||||
// ensureQuotes 保证句库够用,返回一批随机的一言。
|
||||
//
|
||||
// 「够用」= 至少 `quoteSeedThreshold` 条;不够就去外网补一批。
|
||||
// 补失败不报错(返回库里现有的那些)——理由见文件头。
|
||||
func ensureQuotes(ctx context.Context) ([]repo.Quote, error) {
|
||||
n, err := repo.CountQuotes(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if n < quoteSeedThreshold {
|
||||
/*
|
||||
* 去外网补。★ 用**独立的 context 与超时**,不继承请求的:
|
||||
* 请求 context 会在响应写出后取消,而这次拉取不该被它牵连;
|
||||
* 同时 4 秒上限保证顶栏不会因为外网慢而卡住。
|
||||
*/
|
||||
fetchCtx, cancel := context.WithTimeout(context.Background(), quoteHTTPTimeout)
|
||||
defer cancel()
|
||||
if fetched := fetchHitokoto(fetchCtx); len(fetched) > 0 {
|
||||
/* 写库失败也不报错:本次仍能用 fetched 返回 */
|
||||
_, _ = repo.InsertQuotes(ctx, fetched, "hitokoto")
|
||||
}
|
||||
}
|
||||
return repo.RandomQuotes(ctx, quoteBatchSize)
|
||||
}
|
||||
|
||||
// fetchHitokoto 从一言接口拉一批句子。任何失败都返回 nil(不抛错)。
|
||||
//
|
||||
// ★ 逐条拉而不是一次拉一条循环:hitokoto 支持 `?c=` 批量但单次仍是一条,
|
||||
//
|
||||
// 而它没有"批量"端点。这里就是循环拉 N 次 —— 只在**首次补库**时发生
|
||||
// (库里够用之后就不再打),所以串行几秒是可接受的。
|
||||
func fetchHitokoto(ctx context.Context) []repo.Quote {
|
||||
client := &http.Client{Timeout: quoteHTTPTimeout}
|
||||
out := []repo.Quote{}
|
||||
for i := 0; i < quoteFetchBatch; i++ {
|
||||
/* 每次进来先看 ctx:前几次可能已经用完预算 */
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return out
|
||||
default:
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, hitokotoURL, nil)
|
||||
if err != nil {
|
||||
return out
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return out
|
||||
}
|
||||
var body struct {
|
||||
Hitokoto string `json:"hitokoto"`
|
||||
From string `json:"from"`
|
||||
FromWho string `json:"from_who"`
|
||||
}
|
||||
decodeErr := json.NewDecoder(resp.Body).Decode(&body)
|
||||
resp.Body.Close()
|
||||
if decodeErr != nil {
|
||||
return out
|
||||
}
|
||||
text := strings.TrimSpace(body.Hitokoto)
|
||||
if text == "" {
|
||||
continue
|
||||
}
|
||||
/*
|
||||
* 出处优先用 `from_who`(作者),退回 `from`(作品名)——
|
||||
* 顶栏那一行显示"—— 作者"比"—— 作品"更像一句话的落款。
|
||||
*/
|
||||
source := strings.TrimSpace(body.FromWho)
|
||||
if source == "" {
|
||||
source = strings.TrimSpace(body.From)
|
||||
}
|
||||
out = append(out, repo.Quote{Text: text, Source: source})
|
||||
}
|
||||
return out
|
||||
}
|
||||
Reference in New Issue
Block a user