gitcat
1// Package store 负责 SQLite 持久化:站点设置、用户、会话、仓库与推送记录。
2package store
3
4import (
5 "crypto/rand"
6 "database/sql"
7 "encoding/hex"
8 "errors"
9 "fmt"
10 "os"
11 "path/filepath"
12 "strconv"
13 "strings"
14 "time"
15
16 _ "modernc.org/sqlite"
17)
18
19// ErrNotFound 表示记录不存在。
20var ErrNotFound = errors.New("not found")
21
22const timeFormat = time.RFC3339Nano
23
24// Store 是数据库访问入口。
25type Store struct {
26 db *sql.DB
27 dbPath string // 用于迁移前备份;为空表示不备份
28}
29
30// Open 打开(必要时创建)SQLite 数据库并完成建表。
31func Open(path string) (*Store, error) {
32 dsn := "file:" + path + "?_pragma=busy_timeout(15000)&_pragma=journal_mode(WAL)&_pragma=synchronous(NORMAL)&_pragma=foreign_keys(1)"
33 db, err := sql.Open("sqlite", dsn)
34 if err != nil {
35 return nil, err
36 }
37 // 小型工作室场景:单连接即可,彻底避免 SQLite 写锁竞争。
38 db.SetMaxOpenConns(1)
39 if err := db.Ping(); err != nil {
40 _ = db.Close()
41 return nil, err
42 }
43 s := &Store{db: db, dbPath: path}
44 if err := s.migrate(); err != nil {
45 _ = db.Close()
46 return nil, err
47 }
48 return s, nil
49}
50
51// Close 关闭数据库。
52func (s *Store) Close() error { return s.db.Close() }
53
54// schema 是基线结构,按顺序执行。
55//
56// 注意语句顺序:SQLite 不允许为尚不存在的表建索引,所以索引必须排在其
57// 对应的 CREATE TABLE 之后。
58var schema = []string{
59 `CREATE TABLE IF NOT EXISTS settings (
60 key TEXT PRIMARY KEY,
61 value TEXT NOT NULL DEFAULT ''
62 )`,
63 `CREATE TABLE IF NOT EXISTS users (
64 id INTEGER PRIMARY KEY AUTOINCREMENT,
65 username TEXT NOT NULL UNIQUE COLLATE NOCASE,
66 display_name TEXT NOT NULL DEFAULT '',
67 email TEXT NOT NULL DEFAULT '',
68 bio TEXT NOT NULL DEFAULT '',
69 password_hash TEXT NOT NULL,
70 avatar TEXT NOT NULL DEFAULT '',
71 is_admin INTEGER NOT NULL DEFAULT 0,
72 is_disabled INTEGER NOT NULL DEFAULT 0,
73 created_at TEXT NOT NULL,
74 last_login_at TEXT NOT NULL DEFAULT ''
75 )`,
76 `CREATE TABLE IF NOT EXISTS sessions (
77 id TEXT PRIMARY KEY,
78 user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
79 csrf TEXT NOT NULL DEFAULT '',
80 ip TEXT NOT NULL DEFAULT '',
81 user_agent TEXT NOT NULL DEFAULT '',
82 created_at TEXT NOT NULL,
83 expires_at TEXT NOT NULL
84 )`,
85 `CREATE TABLE IF NOT EXISTS repos (
86 id INTEGER PRIMARY KEY AUTOINCREMENT,
87 name TEXT NOT NULL UNIQUE COLLATE NOCASE,
88 description TEXT NOT NULL DEFAULT '',
89 owner_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
90 default_branch TEXT NOT NULL DEFAULT 'main',
91 is_archived INTEGER NOT NULL DEFAULT 0,
92 ai_enabled INTEGER NOT NULL DEFAULT 1,
93 created_at TEXT NOT NULL,
94 updated_at TEXT NOT NULL
95 )`,
96 `CREATE TABLE IF NOT EXISTS pushes (
97 id INTEGER PRIMARY KEY AUTOINCREMENT,
98 repo_id INTEGER NOT NULL REFERENCES repos(id) ON DELETE CASCADE,
99 user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
100 actor_username TEXT NOT NULL DEFAULT '',
101 actor_display TEXT NOT NULL DEFAULT '',
102 ref TEXT NOT NULL DEFAULT '',
103 old_sha TEXT NOT NULL DEFAULT '',
104 new_sha TEXT NOT NULL DEFAULT '',
105 commit_count INTEGER NOT NULL DEFAULT 0,
106 details TEXT NOT NULL DEFAULT '[]',
107 whats_new TEXT NOT NULL DEFAULT '',
108 ai_status TEXT NOT NULL DEFAULT 'pending',
109 created_at TEXT NOT NULL
110 )`,
111
112 // 索引。原先只有三处,而 ListUsers / ListRepos 恰恰在 owner_id、
113 // user_id 上做相关子查询,列表页每次都要全表扫。
114 `CREATE INDEX IF NOT EXISTS idx_repos_owner ON repos(owner_id)`,
115 `CREATE INDEX IF NOT EXISTS idx_repos_updated ON repos(updated_at DESC)`,
116 `CREATE INDEX IF NOT EXISTS idx_pushes_repo ON pushes(repo_id, id DESC)`,
117 `CREATE INDEX IF NOT EXISTS idx_pushes_created ON pushes(created_at DESC)`,
118 `CREATE INDEX IF NOT EXISTS idx_pushes_user ON pushes(user_id, id DESC)`,
119 `CREATE INDEX IF NOT EXISTS idx_sessions_expires ON sessions(expires_at)`,
120 `CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id)`,
121}
122
123// ---------------------------------------------------------------- 迁移
124//
125// schema 里每条语句都是 IF NOT EXISTS,本身幂等;schema_meta 记录的版本号
126// 用来做两件事:
127// 1. 降级保护:数据库版本高于二进制版本时拒绝启动,避免旧代码把新结构写坏。
128// 2. 增量升级:新增的、无法用 IF NOT EXISTS 表达的变更(加列、改类型、
129// 填数据)放进 migrations 切片,按版本号依次执行。
130//
131// 版本 1 = 最原始的 5 张表(无 schema_meta),用于识别旧版数据库。
132
133// SchemaVersion 是当前二进制支持的最新数据库版本。
134const SchemaVersion = 1
135
136// execer 让迁移既能跑在事务里(*sql.Tx)也能直接跑在连接上(*sql.DB)。
137type execer interface {
138 Exec(query string, args ...any) (sql.Result, error)
139 Query(query string, args ...any) (*sql.Rows, error)
140 QueryRow(query string, args ...any) *sql.Row
141}
142
143// migration 是一条按序执行的结构变更。
144type migration struct {
145 version int
146 name string
147 run func(execer) error
148}
149
150// migrations 追加式列表:新增变更时在末尾追加,version 必须递增。
151var migrations = []migration{
152 // 示例:
153 // {version: 2, name: "repos 加 visibility", run: func(db *sql.DB) error {
154 // _, err := db.Exec(`ALTER TABLE repos ADD COLUMN visibility TEXT NOT NULL DEFAULT 'public'`)
155 // return err
156 // }},
157}
158
159const schemaMetaTable = `CREATE TABLE IF NOT EXISTS schema_meta (
160 key TEXT PRIMARY KEY,
161 value TEXT NOT NULL DEFAULT ''
162)`
163
164// dbVersion 读取数据库当前版本;没有 schema_meta 的旧库返回 0。
165func dbVersion(db *sql.DB) (int, error) {
166 var name string
167 err := db.QueryRow(`SELECT name FROM sqlite_master WHERE type='table' AND name='schema_meta'`).Scan(&name)
168 if errors.Is(err, sql.ErrNoRows) {
169 return 0, nil
170 }
171 if err != nil {
172 return 0, err
173 }
174 var v string
175 if err := db.QueryRow(`SELECT value FROM schema_meta WHERE key='schema_version'`).Scan(&v); err != nil {
176 if errors.Is(err, sql.ErrNoRows) {
177 return 0, nil
178 }
179 return 0, err
180 }
181 n, err := strconv.Atoi(strings.TrimSpace(v))
182 if err != nil {
183 return 0, fmt.Errorf("无法解析 schema 版本号 %q", v)
184 }
185 return n, nil
186}
187
188func setDBVersion(db *sql.DB, v int) error {
189 _, err := db.Exec(`INSERT INTO schema_meta(key, value) VALUES('schema_version', ?)
190 ON CONFLICT(key) DO UPDATE SET value = excluded.value`, strconv.Itoa(v))
191 return err
192}
193
194func (s *Store) migrate() error {
195 // 是否需要在迁移前备份,必须在建表之前判断,而且不能只看文件大小:
196 // 驱动一连上就会创建出带页头的文件,"全新安装"看起来也像是有内容的库。
197 // 精确判据是"users 表是否已存在"。
198 var existing int
199 err := s.db.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name IN ('users','repos','pushes')`).Scan(&existing)
200 if err != nil {
201 return fmt.Errorf("migrate: %w", err)
202 }
203 hasLegacyData := existing > 0
204
205 for _, stmt := range schema {
206 if _, err := s.db.Exec(stmt); err != nil {
207 return fmt.Errorf("migrate: %w", err)
208 }
209 }
210 if _, err := s.db.Exec(schemaMetaTable); err != nil {
211 return fmt.Errorf("migrate: %w", err)
212 }
213 current, err := dbVersion(s.db)
214 if err != nil {
215 return fmt.Errorf("migrate: %w", err)
216 }
217 // 降级保护:用旧二进制打开被新版本迁移过的库,字段可能对不上,
218 // 与其运行到一半报诡异错误,不如明确拒绝启动。
219 if current > SchemaVersion {
220 return fmt.Errorf("数据库结构版本为 v%d,高于当前程序支持的 v%d;请先升级 gitcat 再启动", current, SchemaVersion)
221 }
222 if current == SchemaVersion {
223 return nil
224 }
225
226 if hasLegacyData {
227 s.backup()
228 }
229
230 for _, m := range migrations {
231 if m.version <= current {
232 continue
233 }
234 if err := s.applyMigration(m); err != nil {
235 return fmt.Errorf("迁移 v%d(%s)失败: %w", m.version, m.name, err)
236 }
237 }
238 return setDBVersion(s.db, SchemaVersion)
239}
240
241// applyMigration 在一个事务里执行单条迁移。
242func (s *Store) applyMigration(m migration) error {
243 tx, err := s.db.Begin()
244 if err != nil {
245 return err
246 }
247 defer func() { _ = tx.Rollback() }()
248 if err := m.run(tx); err != nil {
249 return err
250 }
251 if _, err := tx.Exec(`INSERT INTO schema_meta(key, value) VALUES('schema_version', ?)
252 ON CONFLICT(key) DO UPDATE SET value = excluded.value`, strconv.Itoa(m.version)); err != nil {
253 return err
254 }
255 return tx.Commit()
256}
257
258// backupDir 是数据库备份目录名。
259const backupDir = "backups"
260
261// backup 用 SQLite 的 VACUUM INTO 生成一个一致性快照。
262//
263// 迁移前留一份副本,是为了在迁移逻辑有问题时还能把数据捞回来。
264// 首次安装(空库)不需要备份。
265func (s *Store) backup() {
266 if s.dbPath == "" {
267 return
268 }
269 st, err := os.Stat(s.dbPath)
270 if err != nil || st.Size() < 4096 {
271 return // 还没建过表的空库
272 }
273 dir := filepath.Join(filepath.Dir(s.dbPath), backupDir)
274 if err := os.MkdirAll(dir, 0o755); err != nil {
275 fmt.Fprintf(os.Stderr, "创建备份目录失败: %v\n", err)
276 return
277 }
278 // 文件名带随机后缀:同一秒内连续两次启动(例如服务自动重启)也不会撞名。
279 dst := filepath.Join(dir, fmt.Sprintf("gitcat-%s-%s.db",
280 time.Now().Format("20060102-150405"), randomToken(4)))
281 // VACUUM INTO 的目标路径不能是绑定参数(SQLite 语法限制),只能自己拼;
282 // 时间与随机串都由本进程生成,不含外部输入。
283 if _, err := s.db.Exec(`VACUUM INTO ?`, dst); err != nil {
284 fmt.Fprintf(os.Stderr, "迁移前备份数据库失败(不影响升级继续): %v\n", err)
285 return
286 }
287 fmt.Fprintf(os.Stderr, "迁移前已备份数据库: %s\n", dst)
288}
289
290// SchemaVersion 返回数据库当前的版本号。
291func (s *Store) SchemaVersion() int {
292 v, err := dbVersion(s.db)
293 if err != nil {
294 return 0
295 }
296 return v
297}
298
299func nowString() string { return time.Now().UTC().Format(timeFormat) }
300
301func parseTime(s string) time.Time {
302 if s == "" {
303 return time.Time{}
304 }
305 if t, err := time.Parse(timeFormat, s); err == nil {
306 return t
307 }
308 if t, err := time.Parse(time.RFC3339, s); err == nil {
309 return t
310 }
311 return time.Time{}
312}
313
314func isUniqueErr(err error) bool {
315 return err != nil && strings.Contains(strings.ToLower(err.Error()), "unique constraint")
316}
317
318// randomToken 生成用于文件名等非安全场景的短随机串。
319func randomToken(n int) string {
320 b := make([]byte, n)
321 if _, err := rand.Read(b); err != nil {
322 return strconv.FormatInt(time.Now().UnixNano(), 36)
323 }
324 return hex.EncodeToString(b)
325}
326
327// ---------------------------------------------------------------- settings
328
329// GetSetting 读取配置项,不存在时返回空串。
330func (s *Store) GetSetting(key string) string {
331 var v string
332 err := s.db.QueryRow(`SELECT value FROM settings WHERE key = ?`, key).Scan(&v)
333 if err != nil {
334 return ""
335 }
336 return v
337}
338
339// SetSetting 写入(或覆盖)配置项。
340func (s *Store) SetSetting(key, value string) error {
341 _, err := s.db.Exec(`INSERT INTO settings(key, value) VALUES(?, ?)
342 ON CONFLICT(key) DO UPDATE SET value = excluded.value`, key, value)
343 return err
344}
345
346// Settings 返回全部配置项。
347func (s *Store) Settings() map[string]string {
348 out := map[string]string{}
349 rows, err := s.db.Query(`SELECT key, value FROM settings`)
350 if err != nil {
351 return out
352 }
353 defer rows.Close()
354 for rows.Next() {
355 var k, v string
356 if rows.Scan(&k, &v) == nil {
357 out[k] = v
358 }
359 }
360 return out
361}
362
363// Installed 判断是否已完成安装。
364func (s *Store) Installed() bool { return s.GetSetting("installed") == "1" }
365
366// ---------------------------------------------------------------- users
367
368// User 表示一个站点账号。
369type User struct {
370 ID int64
371 Username string
372 DisplayName string
373 Email string
374 Bio string
375 PasswordHash string
376 Avatar string
377 IsAdmin bool
378 IsDisabled bool
379 CreatedAt time.Time
380 LastLoginAt time.Time
381
382 // 统计字段(按需填充)
383 RepoCount int
384 PushCount int
385}
386
387// Display 返回用于展示的名字。
388func (u *User) Display() string {
389 if u != nil && u.DisplayName != "" {
390 return u.DisplayName
391 }
392 if u == nil {
393 return ""
394 }
395 return u.Username
396}
397
398// Initial 返回头像占位字符。
399func (u *User) Initial() string {
400 name := u.Display()
401 for _, r := range name {
402 return strings.ToUpper(string(r))
403 }
404 return "?"
405}
406
407func scanUser(row interface {
408 Scan(dest ...any) error
409}) (*User, error) {
410 var u User
411 var created, lastLogin string
412 var admin, disabled int
413 err := row.Scan(&u.ID, &u.Username, &u.DisplayName, &u.Email, &u.Bio, &u.PasswordHash,
414 &u.Avatar, &admin, &disabled, &created, &lastLogin)
415 if err != nil {
416 if errors.Is(err, sql.ErrNoRows) {
417 return nil, ErrNotFound
418 }
419 return nil, err
420 }
421 u.IsAdmin = admin == 1
422 u.IsDisabled = disabled == 1
423 u.CreatedAt = parseTime(created)
424 u.LastLoginAt = parseTime(lastLogin)
425 return &u, nil
426}
427
428const userCols = `id, username, display_name, email, bio, password_hash, avatar, is_admin, is_disabled, created_at, last_login_at`
429
430// CreateUser 新建账号。
431func (s *Store) CreateUser(username, display, email, hash string, isAdmin bool) (*User, error) {
432 ts := nowString()
433 admin := 0
434 if isAdmin {
435 admin = 1
436 }
437 res, err := s.db.Exec(`INSERT INTO users(username, display_name, email, password_hash, is_admin, created_at)
438 VALUES(?, ?, ?, ?, ?, ?)`, username, display, email, hash, admin, ts)
439 if err != nil {
440 if isUniqueErr(err) {
441 return nil, fmt.Errorf("用户名 %q 已存在", username)
442 }
443 return nil, err
444 }
445 id, _ := res.LastInsertId()
446 return s.UserByID(id)
447}
448
449// UserByID 按主键查询用户。
450func (s *Store) UserByID(id int64) (*User, error) {
451 return scanUser(s.db.QueryRow(`SELECT `+userCols+` FROM users WHERE id = ?`, id))
452}
453
454// UserByUsername 按用户名查询(不区分大小写)。
455func (s *Store) UserByUsername(name string) (*User, error) {
456 return scanUser(s.db.QueryRow(`SELECT `+userCols+` FROM users WHERE username = ? COLLATE NOCASE`, name))
457}
458
459// UserByEmail 按邮箱查询。
460func (s *Store) UserByEmail(email string) (*User, error) {
461 return scanUser(s.db.QueryRow(`SELECT `+userCols+` FROM users WHERE email = ? COLLATE NOCASE AND email <> ''`, email))
462}
463
464// ListUsers 返回所有用户,附带仓库数与推送数。
465func (s *Store) ListUsers() ([]*User, error) {
466 rows, err := s.db.Query(`SELECT ` + userCols + `,
467 (SELECT COUNT(*) FROM repos r WHERE r.owner_id = users.id),
468 (SELECT COUNT(*) FROM pushes p WHERE p.user_id = users.id)
469 FROM users ORDER BY is_admin DESC, username COLLATE NOCASE ASC`)
470 if err != nil {
471 return nil, err
472 }
473 defer rows.Close()
474 var out []*User
475 for rows.Next() {
476 var u User
477 var created, lastLogin string
478 var admin, disabled int
479 if err := rows.Scan(&u.ID, &u.Username, &u.DisplayName, &u.Email, &u.Bio, &u.PasswordHash,
480 &u.Avatar, &admin, &disabled, &created, &lastLogin, &u.RepoCount, &u.PushCount); err != nil {
481 return nil, err
482 }
483 u.IsAdmin = admin == 1
484 u.IsDisabled = disabled == 1
485 u.CreatedAt = parseTime(created)
486 u.LastLoginAt = parseTime(lastLogin)
487 out = append(out, &u)
488 }
489 return out, rows.Err()
490}
491
492// UpdateProfile 更新用户资料(不含密码)。
493func (s *Store) UpdateProfile(id int64, display, email, bio, avatar string) error {
494 _, err := s.db.Exec(`UPDATE users SET display_name = ?, email = ?, bio = ?, avatar = ? WHERE id = ?`,
495 display, email, bio, avatar, id)
496 return err
497}
498
499// SetPassword 更新密码哈希。
500func (s *Store) SetPassword(id int64, hash string) error {
501 _, err := s.db.Exec(`UPDATE users SET password_hash = ? WHERE id = ?`, hash, id)
502 return err
503}
504
505// SetUserFlags 更新管理员 / 禁用状态。
506func (s *Store) SetUserFlags(id int64, isAdmin, isDisabled bool) error {
507 b2i := func(b bool) int {
508 if b {
509 return 1
510 }
511 return 0
512 }
513 _, err := s.db.Exec(`UPDATE users SET is_admin = ?, is_disabled = ? WHERE id = ?`, b2i(isAdmin), b2i(isDisabled), id)
514 return err
515}
516
517// TouchLogin 记录最近登录时间。
518func (s *Store) TouchLogin(id int64) error {
519 _, err := s.db.Exec(`UPDATE users SET last_login_at = ? WHERE id = ?`, nowString(), id)
520 return err
521}
522
523// DeleteUser 删除用户(仓库 owner 置空,推送记录保留人名快照)。
524func (s *Store) DeleteUser(id int64) error {
525 _, err := s.db.Exec(`DELETE FROM users WHERE id = ?`, id)
526 return err
527}
528
529// Counts 返回用户 / 仓库 / 推送总数。
530func (s *Store) Counts() (users, repos, pushes int, err error) {
531 err = s.db.QueryRow(`SELECT
532 (SELECT COUNT(*) FROM users),
533 (SELECT COUNT(*) FROM repos),
534 (SELECT COUNT(*) FROM pushes)`).Scan(&users, &repos, &pushes)
535 return
536}
537
538// CountAdmins 返回管理员数量。
539func (s *Store) CountAdmins() (int, error) {
540 var n int
541 err := s.db.QueryRow(`SELECT COUNT(*) FROM users WHERE is_admin = 1`).Scan(&n)
542 return n, err
543}
544
545// ---------------------------------------------------------------- sessions
546
547// Session 表示一次登录会话。
548type Session struct {
549 ID string
550 UserID int64
551 CSRF string
552 CreatedAt time.Time
553 ExpiresAt time.Time
554}
555
556// CreateSession 写入会话记录。
557func (s *Store) CreateSession(id string, userID int64, csrf string, ttl time.Duration, ip, ua string) error {
558 ts := time.Now().UTC()
559 _, err := s.db.Exec(`INSERT INTO sessions(id, user_id, csrf, ip, user_agent, created_at, expires_at)
560 VALUES(?, ?, ?, ?, ?, ?, ?)`, id, userID, csrf, ip, ua,
561 ts.Format(timeFormat), ts.Add(ttl).Format(timeFormat))
562 return err
563}
564
565// SessionByID 查询有效会话,并返回对应用户。
566func (s *Store) SessionByID(id string) (*Session, *User, error) {
567 var sess Session
568 var created, expires string
569 err := s.db.QueryRow(`SELECT id, user_id, csrf, created_at, expires_at FROM sessions WHERE id = ?`, id).
570 Scan(&sess.ID, &sess.UserID, &sess.CSRF, &created, &expires)
571 if err != nil {
572 if errors.Is(err, sql.ErrNoRows) {
573 return nil, nil, ErrNotFound
574 }
575 return nil, nil, err
576 }
577 sess.CreatedAt = parseTime(created)
578 sess.ExpiresAt = parseTime(expires)
579 if time.Now().After(sess.ExpiresAt) {
580 _ = s.DeleteSession(id)
581 return nil, nil, ErrNotFound
582 }
583 u, err := s.UserByID(sess.UserID)
584 if err != nil {
585 return nil, nil, err
586 }
587 if u.IsDisabled {
588 _ = s.DeleteSession(id)
589 return nil, nil, ErrNotFound
590 }
591 return &sess, u, nil
592}
593
594// DeleteSession 删除会话(退出登录)。
595func (s *Store) DeleteSession(id string) error {
596 _, err := s.db.Exec(`DELETE FROM sessions WHERE id = ?`, id)
597 return err
598}
599
600// CleanupSessions 清理过期会话。
601func (s *Store) CleanupSessions() error {
602 _, err := s.db.Exec(`DELETE FROM sessions WHERE expires_at < ?`, nowString())
603 return err
604}