fix up git

This commit is contained in:
2026-06-30 15:06:12 +07:00
parent 11e5a76977
commit 38e22b3390
13 changed files with 883 additions and 18 deletions

View File

@@ -63,6 +63,7 @@ func AutoMigrate(db *gorm.DB) error {
&models.ExamRoomStudent{},
&models.ExamSubmission{},
&models.StaffGitHubAuth{},
&models.StaffGitHubRepo{},
); err != nil {
return err
}

View File

@@ -231,6 +231,10 @@ func bundleSubmissionZips(rows []submissionRow, destZipPath string) (warnings []
}
func collectSubmissionZipFiles(rows []submissionRow) (files []gitPathFile, warnings []string) {
return collectSubmissionZipFilesWithPrefix(rows, true)
}
func collectSubmissionZipFilesWithPrefix(rows []submissionRow, useStudentFolder bool) (files []gitPathFile, warnings []string) {
byPath := map[string]gitPathFile{}
usedFolders := map[string]int{}
for _, row := range rows {
@@ -238,13 +242,17 @@ func collectSubmissionZipFiles(rows []submissionRow) (files []gitPathFile, warni
warnings = append(warnings, fmt.Sprintf("bài SV %d: không tìm thấy file", row.StudentRkID))
continue
}
folder := studentSubmissionFolder(row.StudentCode, row.FullName)
if n := usedFolders[folder]; n > 0 {
folder = fmt.Sprintf("%s_%d", folder, n+1)
prefix := ""
if useStudentFolder {
folder := studentSubmissionFolder(row.StudentCode, row.FullName)
if n := usedFolders[folder]; n > 0 {
folder = fmt.Sprintf("%s_%d", folder, n+1)
}
usedFolders[folder]++
prefix = folder
}
usedFolders[folder]++
skipped, err := copySubmissionZipEntries(row.FilePath, folder, func(destPath string, open func() (io.ReadCloser, error), size int64) error {
skipped, err := copySubmissionZipEntries(row.FilePath, prefix, func(destPath string, open func() (io.ReadCloser, error), size int64) error {
if size > githubBlobMaxBytes {
warnings = append(warnings, destPath+": file quá lớn (>100MB), bỏ qua")
return nil
@@ -262,11 +270,11 @@ func collectSubmissionZipFiles(rows []submissionRow) (files []gitPathFile, warni
return nil
})
if err != nil {
warnings = append(warnings, folder+": "+err.Error())
warnings = append(warnings, prefix+": "+err.Error())
continue
}
for _, s := range skipped {
warnings = append(warnings, folder+"/"+s)
warnings = append(warnings, prefix+"/"+s)
}
}
files = make([]gitPathFile, 0, len(byPath))
@@ -453,6 +461,8 @@ func PublishExamSubmissionsGitHandler(db *gorm.DB) fiber.Handler {
room.GitPublishURL = viewURL
_ = db.Save(&room).Error
roomIDCopy := roomID
recordStaffGitHubRepo(db, staffID, owner, repo, repoHTML, models.GitPublishModeRoom, room.Name, &roomIDCopy, nil)
msg := fmt.Sprintf("Đã đẩy %d bài lên GitHub — mỗi sinh viên một folder.", len(rows))
if len(allWarn) > 0 {
@@ -471,3 +481,112 @@ func PublishExamSubmissionsGitHandler(db *gorm.DB) fiber.Handler {
})
}
}
// POST /api/exam-rooms/:id/submissions/publish-git-students — mỗi sinh viên một repo GitHub riêng
func PublishExamSubmissionsGitPerStudentHandler(db *gorm.DB) fiber.Handler {
return func(c *fiber.Ctx) error {
staffID := middleware.StaffIDFromCtx(c)
if staffID == 0 {
return c.Status(401).JSON(fiber.Map{"error": "Unauthorized"})
}
token, ghLogin, err := loadStaffGitHubToken(db, staffID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return c.Status(401).JSON(fiber.Map{
"error": "Chưa kết nối GitHub. Vào Tài khoản của tôi → Kết nối GitHub.",
"needsGitHubOAuth": true,
})
}
return c.Status(500).JSON(fiber.Map{"error": "Không đọc được token GitHub"})
}
roomID, err := parseUintParam(c, "id")
if err != nil {
return c.Status(400).JSON(fiber.Map{"error": "Invalid id"})
}
var room models.ExamRoom
if err := db.First(&room, roomID).Error; err != nil {
return c.Status(404).JSON(fiber.Map{"error": "Không tìm thấy phòng thi"})
}
rows, err := loadExamSubmissionRows(db, roomID)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
}
if len(rows) == 0 {
return c.Status(400).JSON(fiber.Map{"error": "Chưa có bài nộp"})
}
branch := "main"
published := 0
var allWarn []string
var repoURLs []string
type studentRepo struct {
StudentRkID int64 `json:"studentRkId"`
StudentCode string `json:"studentCode"`
FullName string `json:"fullName"`
URL string `json:"url"`
}
studentRepos := make([]studentRepo, 0, len(rows))
for _, row := range rows {
files, unpackWarn := collectSubmissionZipFilesWithPrefix([]submissionRow{row}, false)
allWarn = append(allWarn, unpackWarn...)
if len(files) == 0 {
allWarn = append(allWarn, fmt.Sprintf("%s: không có file để đẩy", studentSubmissionFolder(row.StudentCode, row.FullName)))
continue
}
repoName, repoHTML, err := githubEnsureRepoForStudent(token, ghLogin, &room, &row)
if err != nil {
allWarn = append(allWarn, row.StudentCode+": "+err.Error())
continue
}
label := studentSubmissionFolder(row.StudentCode, row.FullName)
commitMsg := fmt.Sprintf("Simple Care: bài nộp %s — %s", label, room.Name)
viewURL, pushWarn, err := githubPushFiles(token, ghLogin, repoName, branch, files, commitMsg)
allWarn = append(allWarn, pushWarn...)
if err != nil {
allWarn = append(allWarn, label+": "+err.Error())
continue
}
_ = db.Model(&models.ExamSubmission{}).Where("id = ?", row.ID).
Updates(map[string]any{"git_repo_url": repoHTML, "git_publish_url": viewURL}).Error
subID := row.ID
roomIDCopy := roomID
recordStaffGitHubRepo(db, staffID, ghLogin, repoName, repoHTML, models.GitPublishModeStudent, label, &roomIDCopy, &subID)
published++
repoURLs = append(repoURLs, viewURL)
studentRepos = append(studentRepos, studentRepo{
StudentRkID: row.StudentRkID,
StudentCode: row.StudentCode,
FullName: row.FullName,
URL: viewURL,
})
}
if published == 0 {
return c.Status(502).JSON(fiber.Map{
"error": "Không đẩy được repo nào",
"warnings": allWarn,
})
}
msg := fmt.Sprintf("Đã tạo %d repo GitHub — mỗi sinh viên một repo.", published)
if len(allWarn) > 0 {
msg += fmt.Sprintf(" (%d cảnh báo)", len(allWarn))
}
openURL := ""
if len(repoURLs) > 0 {
openURL = repoURLs[0]
}
return c.JSON(fiber.Map{
"ok": true,
"published": published,
"studentRepos": studentRepos,
"openUrl": openURL,
"warnings": allWarn,
"message": msg,
})
}
}

View File

@@ -64,7 +64,7 @@ func GitHubAuthorizeURLHandler(db *gorm.DB) fiber.Handler {
"https://github.com/login/oauth/authorize?client_id=%s&redirect_uri=%s&scope=%s&state=%s",
url.QueryEscape(clientID),
url.QueryEscape(redirectURI),
url.QueryEscape("repo"),
url.QueryEscape("repo delete_repo"),
url.QueryEscape(state),
)
return c.JSON(fiber.Map{"authorizeUrl": authorizeURL})
@@ -192,10 +192,11 @@ func GitHubStatusHandler(db *gorm.DB) fiber.Handler {
return c.JSON(fiber.Map{"connected": false})
}
return c.JSON(fiber.Map{
"connected": true,
"githubLogin": row.GitHubLogin,
"connectedAt": row.ConnectedAt,
"scope": row.Scope,
"connected": true,
"githubLogin": row.GitHubLogin,
"connectedAt": row.ConnectedAt,
"scope": row.Scope,
"canDeleteRepos": githubScopeAllowsDelete(row.Scope),
})
}
}
@@ -209,6 +210,21 @@ func GitHubDisconnectHandler(db *gorm.DB) fiber.Handler {
}
}
func githubScopeAllowsDelete(scope string) bool {
scope = strings.ToLower(strings.TrimSpace(scope))
if scope == "" {
return false
}
for _, part := range strings.FieldsFunc(scope, func(r rune) bool {
return r == ' ' || r == ','
}) {
if part == "delete_repo" {
return true
}
}
return false
}
func githubGetUser(token string) (login string, err error) {
req, _ := http.NewRequest(http.MethodGet, "https://api.github.com/user", nil)
req.Header.Set("Authorization", "Bearer "+token)
@@ -399,3 +415,61 @@ func resolvePublishRepo(token, ghLogin string, room *models.ExamRoom) (owner, re
}
return owner, repo, branch, htmlURL, nil
}
func studentGitRepoNameCandidates(row submissionRow, room *models.ExamRoom) []string {
base := trimGitHubRepoName(strings.ToLower(studentSubmissionFolder(row.StudentCode, row.FullName)))
candidates := []string{
base,
fmt.Sprintf("%s-exam-%d", base, room.ID),
fmt.Sprintf("%s-%d", base, row.StudentRkID),
fmt.Sprintf("exam-%d-%d", room.ID, row.StudentRkID),
}
seen := map[string]bool{}
out := make([]string, 0, len(candidates))
for _, c := range candidates {
c = trimGitHubRepoName(c)
if seen[c] {
continue
}
seen[c] = true
out = append(out, c)
}
for i := 2; i <= 10; i++ {
c := trimGitHubRepoName(fmt.Sprintf("%s-%d", base, i))
if !seen[c] {
seen[c] = true
out = append(out, c)
}
}
return out
}
func githubEnsureRepoForStudent(token, owner string, room *models.ExamRoom, row *submissionRow) (repoName, htmlURL string, err error) {
if u := strings.TrimSpace(row.GitRepoURL); u != "" {
o, r, parseErr := parseGitHubRepo(u)
if parseErr == nil && strings.EqualFold(o, owner) {
if url, ok := githubGetRepo(token, owner, r); ok {
return r, url, nil
}
}
}
for _, candidate := range studentGitRepoNameCandidates(*row, room) {
if url, ok := githubGetRepo(token, owner, candidate); ok {
if u := strings.TrimSpace(row.GitRepoURL); u != "" {
if o, r, parseErr := parseGitHubRepo(u); parseErr == nil && strings.EqualFold(o, owner) && strings.EqualFold(r, candidate) {
return candidate, url, nil
}
}
continue
}
url, createErr := githubCreateRepo(token, owner, candidate, true)
if createErr == nil {
return candidate, url, nil
}
if isGitHubRepoNameTaken(createErr) {
continue
}
return "", "", createErr
}
return "", "", errors.New("không tạo được repo GitHub cho sinh viên")
}

View File

@@ -0,0 +1,322 @@
package handlers
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"server/internal/middleware"
"server/internal/models"
"github.com/gofiber/fiber/v2"
"gorm.io/gorm"
)
type githubRepoListItem struct {
Key string `json:"key"`
OwnerLogin string `json:"ownerLogin"`
RepoName string `json:"repoName"`
RepoHTMLURL string `json:"repoHtmlUrl"`
Private bool `json:"private"`
PublishMode string `json:"publishMode,omitempty"`
ExamRoomID *uint `json:"examRoomId,omitempty"`
ExamRoomName string `json:"examRoomName,omitempty"`
StudentLabel string `json:"studentLabel,omitempty"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
FromSimpleCare bool `json:"fromSimpleCare"`
}
func githubDeleteRepo(token, owner, repo string) error {
apiURL := fmt.Sprintf("https://api.github.com/repos/%s/%s", url.PathEscape(owner), url.PathEscape(repo))
req, err := http.NewRequest(http.MethodDelete, apiURL, nil)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Accept", "application/vnd.github+json")
req.Header.Set("X-GitHub-Api-Version", "2022-11-28")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return nil
}
if resp.StatusCode >= 300 {
raw, _ := io.ReadAll(resp.Body)
msg := strings.TrimSpace(string(raw))
var errBody struct {
Message string `json:"message"`
}
_ = json.Unmarshal(raw, &errBody)
if m := strings.TrimSpace(errBody.Message); m != "" {
msg = m
}
if msg == "" {
msg = fmt.Sprintf("HTTP %d", resp.StatusCode)
}
if resp.StatusCode == http.StatusForbidden {
return fmt.Errorf("%s — token thiếu quyền delete_repo; ngắt kết nối rồi kết nối lại GitHub", msg)
}
return fmt.Errorf("%s", msg)
}
return nil
}
func githubListUserRepos(token string) ([]struct {
Name string `json:"name"`
FullName string `json:"full_name"`
HTMLURL string `json:"html_url"`
Private bool `json:"private"`
Description string `json:"description"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}, error) {
var all []struct {
Name string `json:"name"`
FullName string `json:"full_name"`
HTMLURL string `json:"html_url"`
Private bool `json:"private"`
Description string `json:"description"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
for page := 1; page <= 10; page++ {
apiURL := fmt.Sprintf("https://api.github.com/user/repos?per_page=100&page=%d&sort=updated&direction=desc", page)
req, err := http.NewRequest(http.MethodGet, apiURL, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Accept", "application/vnd.github+json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
var batch []struct {
Name string `json:"name"`
FullName string `json:"full_name"`
HTMLURL string `json:"html_url"`
Private bool `json:"private"`
Description string `json:"description"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
if err := json.NewDecoder(resp.Body).Decode(&batch); err != nil {
resp.Body.Close()
return nil, err
}
resp.Body.Close()
if resp.StatusCode >= 300 {
return nil, fmt.Errorf("GitHub API lỗi (%d)", resp.StatusCode)
}
all = append(all, batch...)
if len(batch) < 100 {
break
}
}
return all, nil
}
type repoMeta struct {
PublishMode string
ExamRoomID *uint
ExamRoomName string
StudentLabel string
}
func repoMetaKey(owner, name string) string {
return strings.ToLower(owner) + "/" + strings.ToLower(name)
}
func buildRepoMetaMap(db *gorm.DB, staffID uint) map[string]repoMeta {
out := map[string]repoMeta{}
var tracked []models.StaffGitHubRepo
_ = db.Where("staff_id = ?", staffID).Find(&tracked).Error
roomNames := map[uint]string{}
for _, t := range tracked {
key := repoMetaKey(t.OwnerLogin, t.RepoName)
meta := repoMeta{
PublishMode: t.PublishMode,
ExamRoomID: t.ExamRoomID,
StudentLabel: t.StudentLabel,
}
if t.ExamRoomID != nil {
rid := *t.ExamRoomID
if name, ok := roomNames[rid]; ok {
meta.ExamRoomName = name
} else {
var room models.ExamRoom
if err := db.Select("name").First(&room, rid).Error; err == nil {
roomNames[rid] = room.Name
meta.ExamRoomName = room.Name
}
}
}
out[key] = meta
}
return out
}
func recordStaffGitHubRepo(db *gorm.DB, staffID uint, owner, repoName, htmlURL, publishMode, studentLabel string, examRoomID, submissionID *uint) {
owner = strings.TrimSpace(owner)
repoName = strings.TrimSpace(repoName)
if staffID == 0 || owner == "" || repoName == "" || htmlURL == "" {
return
}
_ = db.Where("staff_id = ? AND owner_login = ? AND repo_name = ?", staffID, owner, repoName).
Delete(&models.StaffGitHubRepo{}).Error
row := models.StaffGitHubRepo{
StaffID: staffID,
OwnerLogin: owner,
RepoName: repoName,
RepoHTMLURL: htmlURL,
ExamRoomID: examRoomID,
SubmissionID: submissionID,
StudentLabel: studentLabel,
PublishMode: publishMode,
}
_ = db.Create(&row).Error
}
// GET /api/auth/github/repos — toàn bộ repo trên tài khoản GitHub đã kết nối
func GitHubListReposHandler(db *gorm.DB) fiber.Handler {
return func(c *fiber.Ctx) error {
staffID := middleware.StaffIDFromCtx(c)
token, ghLogin, err := loadStaffGitHubToken(db, staffID)
if err != nil {
return c.Status(401).JSON(fiber.Map{"error": "Chưa kết nối GitHub"})
}
ghRepos, err := githubListUserRepos(token)
if err != nil {
return c.Status(502).JSON(fiber.Map{"error": "Không tải repo từ GitHub: " + err.Error()})
}
metaMap := buildRepoMetaMap(db, staffID)
out := make([]githubRepoListItem, 0, len(ghRepos))
for _, r := range ghRepos {
parts := strings.SplitN(r.FullName, "/", 2)
owner := ghLogin
if len(parts) == 2 {
owner = parts[0]
}
key := owner + "/" + r.Name
item := githubRepoListItem{
Key: key,
OwnerLogin: owner,
RepoName: r.Name,
RepoHTMLURL: r.HTMLURL,
Private: r.Private,
CreatedAt: r.CreatedAt.Format(time.RFC3339),
UpdatedAt: r.UpdatedAt.Format(time.RFC3339),
FromSimpleCare: strings.Contains(strings.ToLower(r.Description), "simple care"),
}
if meta, ok := metaMap[repoMetaKey(owner, r.Name)]; ok {
item.PublishMode = meta.PublishMode
item.ExamRoomID = meta.ExamRoomID
item.ExamRoomName = meta.ExamRoomName
item.StudentLabel = meta.StudentLabel
item.FromSimpleCare = true
}
out = append(out, item)
}
return c.JSON(fiber.Map{"data": out, "total": len(out), "githubLogin": ghLogin})
}
}
// POST /api/auth/github/repos/bulk-delete — repos: ["owner/name", ...]
func GitHubBulkDeleteReposHandler(db *gorm.DB) fiber.Handler {
return func(c *fiber.Ctx) error {
staffID := middleware.StaffIDFromCtx(c)
token, _, err := loadStaffGitHubToken(db, staffID)
if err != nil {
return c.Status(401).JSON(fiber.Map{"error": "Chưa kết nối GitHub"})
}
var authRow models.StaffGitHubAuth
_ = db.First(&authRow, "staff_id = ?", staffID).Error
if !githubScopeAllowsDelete(authRow.Scope) {
return c.Status(403).JSON(fiber.Map{
"error": "Token GitHub chưa có quyền xóa repo (delete_repo). Ngắt kết nối rồi bấm Kết nối lại GitHub.",
})
}
var req struct {
Repos []string `json:"repos"`
IDs []uint `json:"ids"` // legacy
}
if err := c.BodyParser(&req); err != nil {
return c.Status(400).JSON(fiber.Map{"error": "Invalid body"})
}
targets := make([]struct{ owner, name string }, 0)
for _, raw := range req.Repos {
raw = strings.TrimSpace(raw)
if raw == "" {
continue
}
parts := strings.SplitN(raw, "/", 2)
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
continue
}
targets = append(targets, struct{ owner, name string }{owner: parts[0], name: parts[1]})
}
if len(targets) == 0 && len(req.IDs) > 0 {
var rows []models.StaffGitHubRepo
_ = db.Where("staff_id = ? AND id IN ?", staffID, req.IDs).Find(&rows).Error
for _, row := range rows {
targets = append(targets, struct{ owner, name string }{owner: row.OwnerLogin, name: row.RepoName})
}
}
if len(targets) == 0 {
return c.Status(400).JSON(fiber.Map{"error": "Chọn ít nhất một repo"})
}
deleted := 0
var failures []string
for _, t := range targets {
if err := githubDeleteRepo(token, t.owner, t.name); err != nil {
failures = append(failures, t.name+": "+err.Error())
continue
}
var row models.StaffGitHubRepo
if err := db.Where("staff_id = ? AND owner_login = ? AND repo_name = ?", staffID, t.owner, t.name).First(&row).Error; err == nil {
_ = db.Delete(&row).Error
clearLinkedGitURLs(db, row)
} else {
clearLinkedGitURLsByName(db, t.owner, t.name)
}
deleted++
}
return c.JSON(fiber.Map{
"ok": deleted > 0,
"deleted": deleted,
"failures": failures,
"message": fmt.Sprintf("Đã xóa %d repo trên GitHub", deleted),
})
}
}
func clearLinkedGitURLs(db *gorm.DB, row models.StaffGitHubRepo) {
url := strings.TrimSpace(row.RepoHTMLURL)
if url == "" {
return
}
if row.ExamRoomID != nil && row.PublishMode == models.GitPublishModeRoom {
_ = db.Model(&models.ExamRoom{}).Where("id = ? AND git_repo_url = ?", *row.ExamRoomID, url).
Updates(map[string]any{"git_repo_url": "", "git_publish_url": ""}).Error
}
if row.SubmissionID != nil {
_ = db.Model(&models.ExamSubmission{}).Where("id = ? AND git_repo_url = ?", *row.SubmissionID, url).
Updates(map[string]any{"git_repo_url": "", "git_publish_url": ""}).Error
}
}
func clearLinkedGitURLsByName(db *gorm.DB, owner, repoName string) {
prefix := fmt.Sprintf("https://github.com/%s/%s", owner, repoName)
_ = db.Model(&models.ExamRoom{}).Where("git_repo_url LIKE ?", prefix+"%").
Updates(map[string]any{"git_repo_url": "", "git_publish_url": ""}).Error
_ = db.Model(&models.ExamSubmission{}).Where("git_repo_url LIKE ?", prefix+"%").
Updates(map[string]any{"git_repo_url": "", "git_publish_url": ""}).Error
}

View File

@@ -66,8 +66,10 @@ type ExamSubmission struct {
CreatedAt time.Time `json:"createdAt"`
ExamRoomID uint `gorm:"column:exam_room_id;not null;index" json:"examRoomId"`
StudentRkID int64 `gorm:"column:student_rk_id;not null;index" json:"studentRkId"`
FilePath string `gorm:"column:file_path;size:512;not null" json:"filePath"`
FileName string `gorm:"column:file_name;size:255;not null" json:"fileName"`
FilePath string `gorm:"column:file_path;size:512;not null" json:"filePath"`
FileName string `gorm:"column:file_name;size:255;not null" json:"fileName"`
GitRepoURL string `gorm:"column:git_repo_url;size:512" json:"gitRepoUrl,omitempty"`
GitPublishURL string `gorm:"column:git_publish_url;size:1024" json:"gitPublishUrl,omitempty"`
}
func (ExamSubmission) TableName() string { return "exam_submissions" }

View File

@@ -0,0 +1,26 @@
package models
import "time"
const (
GitPublishModeRoom = "room"
GitPublishModeStudent = "student"
)
// StaffGitHubRepo — repo GitHub do giáo viên tạo qua Simple Care
type StaffGitHubRepo struct {
ID uint `gorm:"primaryKey" json:"id"`
StaffID uint `gorm:"column:staff_id;not null;index" json:"staffId"`
OwnerLogin string `gorm:"column:owner_login;size:128;not null" json:"ownerLogin"`
RepoName string `gorm:"column:repo_name;size:128;not null" json:"repoName"`
RepoHTMLURL string `gorm:"column:repo_html_url;size:512;not null" json:"repoHtmlUrl"`
ExamRoomID *uint `gorm:"column:exam_room_id;index" json:"examRoomId,omitempty"`
ExamRoomName string `gorm:"-" json:"examRoomName,omitempty"`
SubmissionID *uint `gorm:"column:submission_id;index" json:"submissionId,omitempty"`
StudentLabel string `gorm:"column:student_label;size:255" json:"studentLabel,omitempty"`
PublishMode string `gorm:"column:publish_mode;size:16;not null;default:room" json:"publishMode"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (StaffGitHubRepo) TableName() string { return "staff_github_repos" }