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 }