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 }