Files
MailUI4Agents/server/internal/handler/topbar.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

198 lines
6.5 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 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
}