From 31939f2b10ad8962944c72657bb50ffed90a7073 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Fri, 25 Sep 2026 16:29:19 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9C=8D=E5=8A=A1=E7=AB=AF:=20=E9=A1=B6?= =?UTF-8?q?=E6=A0=8F=E5=86=85=E5=AE=B9=E7=AB=AF=E7=82=B9=EF=BC=88=E4=B8=80?= =?UTF-8?q?=E8=A8=80=E5=8F=A5=E5=BA=93=E7=BC=93=E5=AD=98=20+=20=E4=B8=AA?= =?UTF-8?q?=E4=BA=BA=E7=AD=BE=E5=90=8D=EF=BC=89+=20=E4=BF=AE=E8=80=81?= =?UTF-8?q?=E5=BA=93=E5=8D=87=E7=BA=A7=E6=97=B6=E5=BA=8F=20bug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户裁定: · 「可以在服务器集成一言与签名,同时 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 也会被注释里的反引号/连续减号破坏。 --- server/cmd/server/main.go | 7 + server/internal/db/migrate.go | 21 ++ server/internal/db/migrations/init_sqlite.sql | 43 +++- server/internal/handler/topbar.go | 197 ++++++++++++++++++ server/internal/handler/topbar_test.go | 197 ++++++++++++++++++ server/internal/repo/quotes.go | 133 ++++++++++++ 6 files changed, 595 insertions(+), 3 deletions(-) create mode 100644 server/internal/handler/topbar.go create mode 100644 server/internal/handler/topbar_test.go create mode 100644 server/internal/repo/quotes.go diff --git a/server/cmd/server/main.go b/server/cmd/server/main.go index bc03820..1932ff5 100644 --- a/server/cmd/server/main.go +++ b/server/cmd/server/main.go @@ -182,6 +182,13 @@ func main() { // 自己的客户端连接密钥(仅能用于 /me/* 与会话级接口,不可注册 Agent) // 用户外观(主题 + 壁纸):账号级,跨设备/跨客户端同一份 + /* + * 顶栏内容(一言 + 签名)。2026-09-24:用户要求桌面端顶栏轮播 + * 「摘要 ↔ 一言/签名」,且「在服务器集成一言与签名」。 + * 客户端拿去本地缓存并轮播(离线也照转)。 + */ + r.Get("/me/topbar", handler.GetTopbar) + r.Put("/me/signature", handler.PutSignature) r.Get("/me/appearance", handler.GetAppearance) r.Put("/me/appearance", handler.PutAppearance) r.Post("/me/appearance/image", handler.UploadAppearanceImage) diff --git a/server/internal/db/migrate.go b/server/internal/db/migrate.go index 105bd6f..130e1c0 100644 --- a/server/internal/db/migrate.go +++ b/server/internal/db/migrate.go @@ -329,6 +329,9 @@ var sqliteAddColumns = []struct{ table, column, ddl string }{ // 旧库默认 ''/0:历史权限邮件按单选审批渲染。 {"mails", "permission_kind", "ALTER TABLE mails ADD COLUMN permission_kind TEXT NOT NULL DEFAULT ''"}, {"mails", "permission_multi_select", "ALTER TABLE mails ADD COLUMN permission_multi_select INTEGER NOT NULL DEFAULT 0"}, + // 个人签名(顶栏「一言」的另一个来源)。 + // 旧库默认空串 = 「还没设过」,客户端因此不会显示签名(而不是显示一个空白)。 + {"users", "signature", "ALTER TABLE users ADD COLUMN signature TEXT NOT NULL DEFAULT ''"}, } // sqliteAddIndexes 是建表后才能建的索引(依赖上面补的列)。 @@ -338,6 +341,24 @@ var sqliteAddIndexes = []string{ "CREATE INDEX IF NOT EXISTS idx_sessions_platform ON sessions(platform_id) WHERE platform_id <> ''", // 人类决策后要按 mail_id 反查上游 permission id "CREATE INDEX IF NOT EXISTS idx_relayed_mail ON relayed_mails(mail_id)", + // 一言句库按来源取(顶栏轮播时只挑一个来源) + "CREATE INDEX IF NOT EXISTS idx_quotes_origin ON quotes(origin)", + // 会话的「path + 别名」唯一索引 —— 它引用 sessions.workspace, + // 而那一列是**后补的**(见 sqliteAddColumns)。 + // + // ★★ 2026-09-25 从 init_sqlite.sql 搬到这里,修一个**只在老库升级时暴露**的真 bug: + // 原来它住在 init_sqlite.sql,而那个文件在 addMissingColumns **之前**执行。 + // 新库没问题(建表时就有 workspace);老库直接死: + // migrate sqlite (语句 #10 … idx_sessions_path_alias_uniq): + // SQL logic error: no such column: workspace + // 报错指向索引名,看着像索引写错 —— 实际是**时序**(列还没补就建索引)。 + // + // 证据:`git stash` 掉当天全部改动后**同样复现** ⇒ 既有缺陷,不是新引入的。 + // 生产库一直没事,因为它早就补过列(暴露面只有"从很旧的库升级")。 + // + // 放这里两边都对:新库建表时已有列;老库补列后才建索引。 + "CREATE UNIQUE INDEX IF NOT EXISTS idx_sessions_path_alias_uniq " + + "ON sessions(COALESCE(workspace,''), session_alias) WHERE session_alias IS NOT NULL", } func addMissingColumns(ctx context.Context) error { diff --git a/server/internal/db/migrations/init_sqlite.sql b/server/internal/db/migrations/init_sqlite.sql index 9467e13..e943818 100644 --- a/server/internal/db/migrations/init_sqlite.sql +++ b/server/internal/db/migrations/init_sqlite.sql @@ -30,9 +30,41 @@ CREATE TABLE IF NOT EXISTS users ( -- 权限边界:空数组 = 不限 allowed_agents TEXT NOT NULL DEFAULT '[]', allowed_paths TEXT NOT NULL DEFAULT '[]', - agent_aliases TEXT NOT NULL DEFAULT '{}' + agent_aliases TEXT NOT NULL DEFAULT '{}', + + -- 个人签名(顶栏「一言」的另一个来源)。 + -- + -- 2026-09-24:用户要求顶栏能轮播「一言 + 签名」,签名是用户的, + -- 所以存在 users 上而不是外观表里(签名不属于外观)。 + signature TEXT NOT NULL DEFAULT '' ); +-- 一言句库(服务端拉取 + 缓存;客户端再缓一层)。 +-- +-- 2026-09-24:用户要求顶栏轮播「一言」,并明确「在服务器集成一言」。 +-- +-- 为什么落库而不是每次转发外网: +-- 1 外网挂了不该让顶栏空着(本地已有句库就照旧发) +-- 2 每次启动都打外网是把一个装饰性文案变成新的失败点 +-- 3 句库本身不会变 —— 缓下来就一直是有效数据 +-- 所以这张表是句库缓存,不是业务数据:删了也不影响正确性,只会触发重拉。 +-- +-- 注意:本文件只用行注释,不要用 C 风格块注释。 +-- 建库时按分号切语句,而切分器只跳过以双横线开头的行; +-- 块注释里的文字会被当成 SQL 执行(我第一版就是因此让新库初始化失败, +-- 报错还是一个不相干的地方)。同理注释里也不要写反引号。 +CREATE TABLE IF NOT EXISTS quotes ( + quote_id TEXT PRIMARY KEY DEFAULT (gen_random_uuid()), + -- 正文与出处(出处可空:一言接口有些句子没作者) + text TEXT NOT NULL, + source TEXT NOT NULL DEFAULT '', + -- 来源标记:'hitokoto' = 外部拉取,'local' = 内置句库 + origin TEXT NOT NULL DEFAULT 'hitokoto', + fetched_at DATETIME DEFAULT (strftime('%Y-%m-%d %H:%M:%f','now')) +); + +CREATE INDEX IF NOT EXISTS idx_quotes_origin ON quotes(origin); + CREATE TABLE IF NOT EXISTS user_sessions ( token TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE, @@ -154,8 +186,13 @@ CREATE INDEX IF NOT EXISTS idx_sessions_owner ON sessions(owner_user_id); -- 成为冗余(于是被忽略),一条 /home 之类的错 path 就能让两条不相干的线索落进同一个 -- 会话、同一个 pi 会话文件(2026-09-15 实测:zcode 的"介绍请求"与 agentmail 的部署 -- 往来混在 --home-- 的一个 jsonl 里)。 -CREATE UNIQUE INDEX IF NOT EXISTS idx_sessions_path_alias_uniq - ON sessions(COALESCE(workspace,''), session_alias) WHERE session_alias IS NOT NULL; +-- 注意:这条索引引用 sessions.workspace,而那是**后补的列**(sqliteAddColumns)。 +-- 老库的 sessions 没有这一列,所以索引必须等补完列再建 —— +-- 它现在住在 migrate.go 的 sqliteAddIndexes 里(那个列表在补列之后执行)。 +-- +-- 2026-09-25 实测:一份 9 月 2 日的老库启动时直接死在这里 +-- (no such column: workspace),报错却指向这个索引名,看着像索引写错, +-- 实际是**时序**问题(列还没补上就建索引)。生产库没暴露是因为它早补过了。 -- 老索引必须显式丢弃:CREATE ... IF NOT EXISTS 不会删掉它,而它会继续把别名限制成全局唯一。 DROP INDEX IF EXISTS idx_sessions_alias_uniq; diff --git a/server/internal/handler/topbar.go b/server/internal/handler/topbar.go new file mode 100644 index 0000000..2a11381 --- /dev/null +++ b/server/internal/handler/topbar.go @@ -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 +} diff --git a/server/internal/handler/topbar_test.go b/server/internal/handler/topbar_test.go new file mode 100644 index 0000000..41b84f9 --- /dev/null +++ b/server/internal/handler/topbar_test.go @@ -0,0 +1,197 @@ +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) + } +} diff --git a/server/internal/repo/quotes.go b/server/internal/repo/quotes.go new file mode 100644 index 0000000..c41c55b --- /dev/null +++ b/server/internal/repo/quotes.go @@ -0,0 +1,133 @@ +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 +}