用户裁定:
· 「可以在服务器集成一言与签名,同时 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 也会被注释里的反引号/连续减号破坏。
198 lines
7.3 KiB
Go
198 lines
7.3 KiB
Go
package handler
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"encoding/json"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"path/filepath"
|
||
"strings"
|
||
"testing"
|
||
|
||
"github.com/agentmail/gateway/internal/db"
|
||
"github.com/agentmail/gateway/internal/repo"
|
||
)
|
||
|
||
/*
|
||
顶栏内容(一言 + 签名)—— 2026-09-24。
|
||
|
||
用户原话:「可以在服务器集成一言与签名,同时 app 本地缓存一部分」+
|
||
「摘要也应该放在顶部,显示摘要不显示一言,显示一言不显示摘要」+
|
||
「自动轮播,要有消失出现动画。同时注意,是纯文字不要加底」。
|
||
|
||
服务端这半边钉四件事:
|
||
1. 签名存得下、读得回,且**账号级**(与外观同一条纪律);
|
||
2. 一言从库里取,**不打外网**(外网只用于补库);
|
||
3. ★ 外网挂了不影响返回 —— 这是"装饰性内容不该成为失败点"的落实;
|
||
4. 签名限长(顶栏是一行,超了必然被截,静默截断比报错更坏)。
|
||
*/
|
||
|
||
func setupTopbarDB(t *testing.T) {
|
||
t.Helper()
|
||
dir := t.TempDir()
|
||
if err := db.Connect(context.Background(), "sqlite://"+filepath.Join(dir, "t.db")); err != nil {
|
||
t.Fatalf("connect: %v", err)
|
||
}
|
||
if err := db.Migrate(context.Background()); err != nil {
|
||
t.Fatalf("migrate: %v", err)
|
||
}
|
||
for _, u := range []string{"alice", "bob"} {
|
||
if _, err := db.DB.ExecContext(context.Background(),
|
||
`INSERT INTO users (username, password_hash, role) VALUES ($1, 'x', 'user')`, u); err != nil {
|
||
t.Fatalf("建用户 %s: %v", u, err)
|
||
}
|
||
}
|
||
t.Cleanup(func() { db.Close() })
|
||
}
|
||
|
||
func getTopbar(t *testing.T, username string) map[string]any {
|
||
t.Helper()
|
||
req := httptest.NewRequest(http.MethodGet, "/api/v1/me/topbar", nil)
|
||
resp := httptest.NewRecorder()
|
||
GetTopbar(resp, asUser(req, username))
|
||
if resp.Code != http.StatusOK {
|
||
t.Fatalf("GET topbar = %d(%s)", resp.Code, resp.Body.String())
|
||
}
|
||
var out map[string]any
|
||
if err := json.Unmarshal(resp.Body.Bytes(), &out); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
return out
|
||
}
|
||
|
||
func putSignature(t *testing.T, username, sig string) *httptest.ResponseRecorder {
|
||
t.Helper()
|
||
raw, _ := json.Marshal(map[string]string{"signature": sig})
|
||
req := httptest.NewRequest(http.MethodPut, "/api/v1/me/signature", bytes.NewReader(raw))
|
||
req.Header.Set("Content-Type", "application/json")
|
||
resp := httptest.NewRecorder()
|
||
PutSignature(resp, asUser(req, username))
|
||
return resp
|
||
}
|
||
|
||
// ① 签名存得下、读得回,且两个账号互不干扰。
|
||
//
|
||
// 改坏会红:SetSignature 改成不带 username 条件(两个人会互相覆盖)。
|
||
func TestSignatureRoundTripPerAccount(t *testing.T) {
|
||
setupTopbarDB(t)
|
||
|
||
if resp := putSignature(t, "alice", "今天也要好好写代码"); resp.Code != http.StatusOK {
|
||
t.Fatalf("PUT signature = %d(%s)", resp.Code, resp.Body.String())
|
||
}
|
||
if got := getTopbar(t, "alice")["signature"]; got != "今天也要好好写代码" {
|
||
t.Fatalf("alice 的签名 = %v,want 今天也要好好写代码", got)
|
||
}
|
||
/*
|
||
* ★ 这条是重点:bob 没设过 ⇒ 必须是空串,而不是 alice 的那句。
|
||
* 本仓 `user_appearance` 那轮踩过**多账号共用一份**(键是全局常量),
|
||
* 同一个形状不能在签名上重演。
|
||
*/
|
||
if got := getTopbar(t, "bob")["signature"]; got != "" {
|
||
t.Fatalf("bob 的签名 = %v,want 空(他没设过;串到 alice 的值就是多账号未隔离)", got)
|
||
}
|
||
}
|
||
|
||
// ② 一言从库里取;有货就**不打外网**(这是常态路径,也是"缓存"的意义)。
|
||
//
|
||
// 改坏会红:ensureQuotes 改成每次无条件 fetchHitokoto。
|
||
func TestQuotesServedFromLocalCache(t *testing.T) {
|
||
setupTopbarDB(t)
|
||
|
||
ctx := context.Background()
|
||
/*
|
||
* 灌满阈值以上(quoteSeedThreshold = 20),保证走"库里够用"那条路。
|
||
* 造 25 条而不是刚好 20:留余量,免得将来阈值调大这条因数据不足而改走外网分支
|
||
* (那时它会真的联网,判据就变成了不可靠的集成测试)。
|
||
*/
|
||
quotes := make([]repo.Quote, 0, 25)
|
||
for i := 0; i < 25; i++ {
|
||
quotes = append(quotes, repo.Quote{Text: "句子" + string(rune('A'+i)), Source: "出处"})
|
||
}
|
||
if _, err := repo.InsertQuotes(ctx, quotes, "hitokoto"); err != nil {
|
||
t.Fatalf("InsertQuotes: %v", err)
|
||
}
|
||
|
||
out := getTopbar(t, "alice")
|
||
got, _ := out["quotes"].([]any)
|
||
if len(got) == 0 {
|
||
t.Fatal("库里已有 25 条,quotes 不该为空")
|
||
}
|
||
if len(got) > quoteBatchSize {
|
||
t.Fatalf("一次给了 %d 条,超过 quoteBatchSize=%d(客户端只需一批够轮播)", len(got), quoteBatchSize)
|
||
}
|
||
}
|
||
|
||
// ③ ★ 外网挂了不影响返回:句库空 + 外网不通时,接口仍 200 且结构完整。
|
||
//
|
||
// 这是"装饰性内容不该成为失败点"的落实。顶栏少了轮播内容是小事,
|
||
// 整个接口 500 会让 App 启动时顶栏直接坏掉 —— 那才是大事。
|
||
//
|
||
// 改坏会红:ensureQuotes/GetTopbar 把拉取失败当致命错误往上抛。
|
||
func TestTopbarSurvivesQuoteFetchFailure(t *testing.T) {
|
||
setupTopbarDB(t)
|
||
|
||
/*
|
||
* 这里**不 mock 网络**:测试环境本来就没有外网(CI 常驻离线),
|
||
* ensureQuotes 会走 fetchHitokoto 并失败 —— 正是要验的场景。
|
||
* 若哪天测试机真能连上,它也只是"补库成功",下面的断言照样成立
|
||
* (断言的是"能返回"与"结构对",不是"库必须空")。
|
||
*/
|
||
out := getTopbar(t, "alice")
|
||
|
||
if _, ok := out["quotes"]; !ok {
|
||
t.Fatal("quotes 键必须存在(哪怕是空数组)—— 客户端要有东西可解")
|
||
}
|
||
if _, ok := out["signature"]; !ok {
|
||
t.Fatal("signature 键必须存在(哪怕空串)")
|
||
}
|
||
}
|
||
|
||
// ④ 签名限长:超 80 字拒绝,且拒绝时**不落库**。
|
||
//
|
||
// 顶栏是一行文字,超了必然被截。静默截断比报错更坏 ——
|
||
// 用户以为存进去了,实际存的是被砍过的。
|
||
//
|
||
// 改坏会红:去掉长度检查(会返回 200 并把超长串存下来)。
|
||
func TestSignatureLengthLimit(t *testing.T) {
|
||
setupTopbarDB(t)
|
||
|
||
long := strings.Repeat("字", 81)
|
||
resp := putSignature(t, "alice", long)
|
||
if resp.Code != http.StatusBadRequest {
|
||
t.Fatalf("81 字应被拒(400),实际 %d", resp.Code)
|
||
}
|
||
/* 拒绝就不该落库 —— 否则"被拒了但值变了"更让人困惑 */
|
||
if got := getTopbar(t, "alice")["signature"]; got != "" {
|
||
t.Fatalf("超长签名被拒后不该写库,实际存成了 %v", got)
|
||
}
|
||
|
||
/* 边界:正好 80 字要接受(否则是"限长 80"实现成了 79) */
|
||
ok := strings.Repeat("字", 80)
|
||
if resp := putSignature(t, "alice", ok); resp.Code != http.StatusOK {
|
||
t.Fatalf("80 字应被接受,实际 %d(%s)", resp.Code, resp.Body.String())
|
||
}
|
||
}
|
||
|
||
// ⑤ 鉴权:未登录不得读取(顶栏含个人签名,是账号级数据)。
|
||
//
|
||
// 改坏会红:GetTopbar/PutSignature 去掉 middleware.GetUser 判空。
|
||
func TestTopbarRequiresAuth(t *testing.T) {
|
||
setupTopbarDB(t)
|
||
|
||
req := httptest.NewRequest(http.MethodGet, "/api/v1/me/topbar", nil)
|
||
resp := httptest.NewRecorder()
|
||
GetTopbar(resp, req) // 不带用户
|
||
if resp.Code != http.StatusUnauthorized {
|
||
t.Fatalf("未登录读 topbar 应 401,实际 %d", resp.Code)
|
||
}
|
||
|
||
raw, _ := json.Marshal(map[string]string{"signature": "x"})
|
||
req2 := httptest.NewRequest(http.MethodPut, "/api/v1/me/signature", bytes.NewReader(raw))
|
||
resp2 := httptest.NewRecorder()
|
||
PutSignature(resp2, req2)
|
||
if resp2.Code != http.StatusUnauthorized {
|
||
t.Fatalf("未登录写签名应 401,实际 %d", resp2.Code)
|
||
}
|
||
}
|