package store import ( "database/sql" "encoding/json" "errors" "fmt" "strings" "time" ) // Repo 表示一个 Git 仓库。 type Repo struct { ID int64 Name string Description string OwnerID *int64 DefaultBranch string IsArchived bool AIEnabled bool CreatedAt time.Time UpdatedAt time.Time } // RepoCard 是首页 / 后台列表用的仓库条目(附带展示统计)。 type RepoCard struct { Repo OwnerName string PushCount int LastPushAt time.Time LastWhatsNew string LastPusher string } // CreateRepo 新建仓库记录。 func (s *Store) CreateRepo(name, description string, ownerID int64) (*Repo, error) { ts := nowString() res, err := s.db.Exec(`INSERT INTO repos(name, description, owner_id, default_branch, created_at, updated_at) VALUES(?, ?, ?, 'main', ?, ?)`, name, description, ownerID, ts, ts) if err != nil { if isUniqueErr(err) { return nil, fmt.Errorf("项目 %q 已存在", name) } return nil, err } id, _ := res.LastInsertId() return s.RepoByID(id) } const repoCols = `id, name, description, owner_id, default_branch, is_archived, ai_enabled, created_at, updated_at` func scanRepo(row interface { Scan(dest ...any) error }) (*Repo, error) { var r Repo var owner sql.NullInt64 var archived, ai int var created, updated string if err := row.Scan(&r.ID, &r.Name, &r.Description, &owner, &r.DefaultBranch, &archived, &ai, &created, &updated); err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, ErrNotFound } return nil, err } if owner.Valid { id := owner.Int64 r.OwnerID = &id } r.IsArchived = archived == 1 r.AIEnabled = ai == 1 r.CreatedAt = parseTime(created) r.UpdatedAt = parseTime(updated) return &r, nil } // RepoByID 按主键查询仓库。 func (s *Store) RepoByID(id int64) (*Repo, error) { return scanRepo(s.db.QueryRow(`SELECT `+repoCols+` FROM repos WHERE id = ?`, id)) } // RepoByName 按名称查询仓库(不区分大小写)。 func (s *Store) RepoByName(name string) (*Repo, error) { return scanRepo(s.db.QueryRow(`SELECT `+repoCols+` FROM repos WHERE name = ? COLLATE NOCASE`, name)) } // ListRepos 返回仓库列表(含统计),q 非空时按名称/描述模糊搜索。 func (s *Store) ListRepos(q string) ([]*RepoCard, error) { query := `SELECT ` + repoCols + `, COALESCE((SELECT username FROM users u WHERE u.id = repos.owner_id), ''), (SELECT COUNT(*) FROM pushes p WHERE p.repo_id = repos.id), COALESCE((SELECT MAX(created_at) FROM pushes p WHERE p.repo_id = repos.id), repos.updated_at), COALESCE((SELECT whats_new FROM pushes p WHERE p.repo_id = repos.id ORDER BY id DESC LIMIT 1), ''), COALESCE((SELECT actor_display FROM pushes p WHERE p.repo_id = repos.id ORDER BY id DESC LIMIT 1), '') FROM repos` var args []any if strings.TrimSpace(q) != "" { like := "%" + strings.TrimSpace(q) + "%" query += ` WHERE name LIKE ? OR description LIKE ?` args = append(args, like, like) } query += ` ORDER BY updated_at DESC, name COLLATE NOCASE ASC` rows, err := s.db.Query(query, args...) if err != nil { return nil, err } defer rows.Close() var out []*RepoCard for rows.Next() { var c RepoCard var owner sql.NullInt64 var archived, ai int var created, updated, lastPush string if err := rows.Scan(&c.ID, &c.Name, &c.Description, &owner, &c.DefaultBranch, &archived, &ai, &created, &updated, &c.OwnerName, &c.PushCount, &lastPush, &c.LastWhatsNew, &c.LastPusher); err != nil { return nil, err } if owner.Valid { id := owner.Int64 c.OwnerID = &id } c.IsArchived = archived == 1 c.AIEnabled = ai == 1 c.CreatedAt = parseTime(created) c.UpdatedAt = parseTime(updated) c.LastPushAt = parseTime(lastPush) out = append(out, &c) } return out, rows.Err() } // UpdateRepo 保存仓库的可编辑字段。 // // 注意:这里刻意不写 name。仓库名同时决定了磁盘上的目录 // (/repos/.git),如果这里允许改名而调用方忘了同步重命名 // 目录,数据库与磁盘就会脱钩,仓库直接变成"数据缺失"。 // 改名请走 RenameRepo —— 它把"改名字"和"改目录"作为同一个操作。 func (s *Store) UpdateRepo(r *Repo) error { b2i := func(b bool) int { if b { return 1 } return 0 } _, err := s.db.Exec(`UPDATE repos SET description = ?, default_branch = ?, is_archived = ?, ai_enabled = ?, updated_at = ? WHERE id = ?`, r.Description, r.DefaultBranch, b2i(r.IsArchived), b2i(r.AIEnabled), nowString(), r.ID) return err } // RenameRepo 修改仓库名。 // // 调用方负责保证磁盘目录已经(或即将)同步到新名字;本方法只负责数据层。 func (s *Store) RenameRepo(id int64, newName string) error { res, err := s.db.Exec(`UPDATE repos SET name = ?, updated_at = ? WHERE id = ?`, newName, nowString(), id) if err != nil { if isUniqueErr(err) { return fmt.Errorf("项目 %q 已存在", newName) } return err } if n, _ := res.RowsAffected(); n == 0 { return ErrNotFound } return nil } // SetRepoOwner 单独更新仓库的所属者。 func (s *Store) SetRepoOwner(id int64, ownerID *int64) error { if ownerID == nil { _, err := s.db.Exec(`UPDATE repos SET owner_id = NULL WHERE id = ?`, id) return err } _, err := s.db.Exec(`UPDATE repos SET owner_id = ? WHERE id = ?`, *ownerID, id) return err } // TouchRepo 更新仓库的最近活跃时间。 func (s *Store) TouchRepo(id int64) error { _, err := s.db.Exec(`UPDATE repos SET updated_at = ? WHERE id = ?`, nowString(), id) return err } // DeleteRepo 删除仓库记录(git 目录由调用方清理)。 func (s *Store) DeleteRepo(id int64) error { _, err := s.db.Exec(`DELETE FROM repos WHERE id = ?`, id) return err } // PushCommit 是推送记录中单个提交的摘要(用于留痕展示)。 type PushCommit struct { SHA string `json:"sha"` Subject string `json:"subject"` Author string `json:"author"` When int64 `json:"when"` Additions int `json:"additions"` Deletions int `json:"deletions"` Files int `json:"files"` } // Push 表示一次推送事件。 type Push struct { ID int64 RepoID int64 RepoName string UserID *int64 ActorUsername string ActorDisplay string Ref string OldSHA string NewSHA string CommitCount int Details []PushCommit WhatsNew string AIStatus string CreatedAt time.Time } // CreatePush 记录一次推送。 func (s *Store) CreatePush(repoID int64, userID *int64, actorUsername, actorDisplay, ref, oldSHA, newSHA string, details []PushCommit) (*Push, error) { raw, _ := json.Marshal(details) res, err := s.db.Exec(`INSERT INTO pushes(repo_id, user_id, actor_username, actor_display, ref, old_sha, new_sha, commit_count, details, ai_status, created_at) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?)`, repoID, userID, actorUsername, actorDisplay, ref, oldSHA, newSHA, len(details), string(raw), nowString()) if err != nil { return nil, err } id, _ := res.LastInsertId() return s.PushByID(id) } const pushCols = `p.id, p.repo_id, COALESCE(r.name, ''), p.user_id, p.actor_username, p.actor_display, p.ref, p.old_sha, p.new_sha, p.commit_count, p.details, p.whats_new, p.ai_status, p.created_at` func scanPush(scan func(dest ...any) error) (*Push, error) { var p Push var userID sql.NullInt64 var details, created string if err := scan(&p.ID, &p.RepoID, &p.RepoName, &userID, &p.ActorUsername, &p.ActorDisplay, &p.Ref, &p.OldSHA, &p.NewSHA, &p.CommitCount, &details, &p.WhatsNew, &p.AIStatus, &created); err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, ErrNotFound } return nil, err } if userID.Valid { id := userID.Int64 p.UserID = &id } _ = json.Unmarshal([]byte(details), &p.Details) p.CreatedAt = parseTime(created) return &p, nil } // PushByID 按主键查询推送记录。 func (s *Store) PushByID(id int64) (*Push, error) { return scanPush(s.db.QueryRow(`SELECT `+pushCols+` FROM pushes p LEFT JOIN repos r ON r.id = p.repo_id WHERE p.id = ?`, id).Scan) } // PushesByRepo 返回某个仓库的推送记录。 func (s *Store) PushesByRepo(repoID int64, limit int) ([]*Push, error) { rows, err := s.db.Query(`SELECT `+pushCols+` FROM pushes p LEFT JOIN repos r ON r.id = p.repo_id WHERE p.repo_id = ? ORDER BY p.id DESC LIMIT ?`, repoID, limit) if err != nil { return nil, err } defer rows.Close() var out []*Push for rows.Next() { p, err := scanPush(rows.Scan) if err != nil { return nil, err } out = append(out, p) } return out, rows.Err() } // LatestPush 返回仓库最近一次推送,没有则返回 nil。 func (s *Store) LatestPush(repoID int64) (*Push, error) { p, err := scanPush(s.db.QueryRow(`SELECT `+pushCols+` FROM pushes p LEFT JOIN repos r ON r.id = p.repo_id WHERE p.repo_id = ? ORDER BY p.id DESC LIMIT 1`, repoID).Scan) if errors.Is(err, ErrNotFound) { return nil, nil } return p, err } // RecentPushes 返回全站最近的推送动态。 func (s *Store) RecentPushes(limit int) ([]*Push, error) { rows, err := s.db.Query(`SELECT `+pushCols+` FROM pushes p LEFT JOIN repos r ON r.id = p.repo_id ORDER BY p.id DESC LIMIT ?`, limit) if err != nil { return nil, err } defer rows.Close() var out []*Push for rows.Next() { p, err := scanPush(rows.Scan) if err != nil { return nil, err } out = append(out, p) } return out, rows.Err() } // SetPushSummary 写入 AI 生成的 whats new(或降级摘要)。 func (s *Store) SetPushSummary(id int64, whatsNew, status string) error { _, err := s.db.Exec(`UPDATE pushes SET whats_new = ?, ai_status = ? WHERE id = ?`, whatsNew, status, id) return err } // PushesByUser 返回某用户的推送数。 func (s *Store) PushesByUser(userID int64) (int, error) { var n int err := s.db.QueryRow(`SELECT COUNT(*) FROM pushes WHERE user_id = ?`, userID).Scan(&n) return n, err }