服务端: 顶栏内容端点(一言句库缓存 + 个人签名)+ 修老库升级时序 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:
@ -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)
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
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
|
||||
}
|
||||
197
server/internal/handler/topbar_test.go
Normal file
197
server/internal/handler/topbar_test.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
133
server/internal/repo/quotes.go
Normal file
133
server/internal/repo/quotes.go
Normal file
@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user