package handler import ( "bytes" "crypto/hmac" "crypto/rand" "crypto/sha256" "encoding/base64" "encoding/json" "fmt" "html/template" "log" "net/http" "os" "resume-platform/internal/model" "resume-platform/internal/repository" "resume-platform/internal/security" "resume-platform/pkg/config" "runtime" "strconv" "strings" "sync" "time" "github.com/gin-gonic/gin" "github.com/google/uuid" ) const ( tokenExpiryDuration = 7 * 24 * time.Hour tokenRenewInterval = 1 * time.Hour tokenSecret = "resume-platform-admin-secret-key" tokenStorageFile = "./data/admin_tokens.json" settingsFile = "./data/settings.json" ) // parseInt 安全解析整数,解析失败返回默认值 // @param s 待解析字符串 // @param defaultValue 解析失败时的默认值 // @author sunct func parseInt(s string, defaultValue int) int { if s == "" { return defaultValue } v, err := strconv.Atoi(s) if err != nil { return defaultValue } return v } var ( adminTokens = make(map[string]tokenInfo) tokensMutex sync.RWMutex resumeRepo repository.ResumeRepository ) type tokenInfo struct { Username string `json:"username"` ExpiresAt localTime `json:"expires_at"` CreatedAt localTime `json:"created_at"` LastActivity localTime `json:"last_activity"` } type localTime time.Time func (t localTime) MarshalJSON() ([]byte, error) { return []byte(`"` + time.Time(t).Local().Format(time.RFC3339) + `"`), nil } func (t *localTime) UnmarshalJSON(data []byte) error { s := strings.Trim(string(data), `"`) parsed, err := time.Parse(time.RFC3339, s) if err != nil { return err } *t = localTime(parsed.Local()) return nil } // LoadTokens 从本地文件加载已保存的管理员令牌到内存 // @author sunct func LoadTokens() { tokensMutex.Lock() defer tokensMutex.Unlock() data, err := os.ReadFile(tokenStorageFile) if err != nil { return } var storedTokens map[string]tokenInfo if err := json.Unmarshal(data, &storedTokens); err != nil { return } now := time.Now() for token, info := range storedTokens { if now.Before(time.Time(info.ExpiresAt)) { adminTokens[token] = info } } } // SaveTokens 将内存中的管理员令牌持久化到本地文件 // @author sunct func SaveTokens() { tokensMutex.RLock() defer tokensMutex.RUnlock() data, err := json.Marshal(adminTokens) if err != nil { return } os.WriteFile(tokenStorageFile, data, 0600) } // SetResumeRepository 注入简历数据仓库实例,供包级函数使用 // @param repo 数据仓库接口实现 // @author sunct func SetResumeRepository(repo repository.ResumeRepository) { resumeRepo = repo } // AdminHandler 后台管理处理器 type AdminHandler struct { config *config.Config rateLimiter *security.RateLimiter startTime time.Time } // NewAdminHandler 创建后台管理处理器实例 // @param config 系统配置 // @param rateLimiter 限流器 // @return *AdminHandler 处理器实例 // @author sunct func NewAdminHandler(config *config.Config, rateLimiter *security.RateLimiter) *AdminHandler { return &AdminHandler{ config: config, rateLimiter: rateLimiter, startTime: time.Now(), } } // getAdminPath 获取后台登录入口路径,优先使用配置项 // @return string 登录路径 // @author sunct func (h *AdminHandler) getAdminPath() string { if h.config.Auth.AdminPath != "" { return h.config.Auth.AdminPath } return "/admin" } // getDashboardPath 获取后台管理根路径 // @return string 后台路径 // @author sunct func (h *AdminHandler) getDashboardPath() string { return "/dashboard" } // createNotification 创建系统通知的辅助函数 // @param nType 通知类型 // @param title 通知标题 // @param message 通知内容 // @param url 跳转链接 // @author sunct func createNotification(nType, title, message, url string) { if resumeRepo == nil { return } resumeRepo.CreateNotification(&model.Notification{ Type: nType, Title: title, Message: message, URL: url, }) } // LoginPage 渲染后台登录页面 // @param c Gin 上下文 // @author sunct func (h *AdminHandler) LoginPage(c *gin.Context) { t, err := template.ParseFiles("templates/admin/login.html") if err != nil { c.String(http.StatusInternalServerError, "Template load error: "+err.Error()) return } getConfig, _ := resumeRepo.GetSystemConfig() if getConfig == nil { getConfig = &model.SystemConfig{ WebsiteDomain: "http://localhost:8090", WebsiteTitle: "Resume Platform", WebsiteDesc: "现代化的开源简历平台,支持多人简历管理、自定义模板、实时预览", WebsiteKeywords: "简历平台,开源,个人主页,作品集,简历管理", } } var buf bytes.Buffer err = t.Execute(&buf, gin.H{ "adminPath": h.getAdminPath(), "SystemConfig": getConfig, }) if err != nil { c.String(http.StatusInternalServerError, "Template render error: "+err.Error()) return } c.Header("Content-Type", "text/html; charset=utf-8") c.String(http.StatusOK, buf.String()) } // Login 处理管理员登录验证,包含验证码校验、限流和令牌生成 // @param c Gin 上下文 // @author sunct func (h *AdminHandler) Login(c *gin.Context) { ip := c.ClientIP() userAgent := c.GetHeader("User-Agent") allowed, remaining := h.rateLimiter.Allow(ip) if !allowed { h.recordLoginHistory(ip, userAgent, "", "", false, "登录次数过多") c.JSON(http.StatusTooManyRequests, gin.H{"success": false, "message": "登录次数过多,请稍后重试", "remaining": 0}) return } var credentials struct { Username string `json:"username" form:"username"` Password string `json:"password" form:"password"` Captcha string `json:"captcha" form:"captcha"` CaptchaToken string `json:"captcha_token" form:"captcha_token"` } if err := c.ShouldBind(&credentials); err != nil { h.rateLimiter.RecordAttempt(ip) h.recordLoginHistory(ip, userAgent, credentials.Username, "", false, "请求参数错误") c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "请求参数错误", "remaining": remaining - 1}) return } if !security.Verify(credentials.CaptchaToken, credentials.Captcha) { h.rateLimiter.RecordAttempt(ip) h.recordLoginHistory(ip, userAgent, credentials.Username, "", false, "验证码错误") c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "验证码错误", "remaining": remaining - 1}) return } if credentials.Username == h.config.Auth.Username && credentials.Password == h.config.Auth.Password { h.rateLimiter.Reset(ip) newToken := generateEncryptedToken(credentials.Username) now := time.Now() tokensMutex.Lock() for token, info := range adminTokens { if info.Username == credentials.Username { delete(adminTokens, token) } } adminTokens[newToken] = tokenInfo{ Username: credentials.Username, ExpiresAt: localTime(now.Add(tokenExpiryDuration)), CreatedAt: localTime(now), LastActivity: localTime(now), } tokensMutex.Unlock() SaveTokens() h.recordLoginHistory(ip, userAgent, credentials.Username, "管理员", true, "登录成功") c.SetCookie("admin_token", newToken, int(tokenExpiryDuration.Seconds()), "/", "", false, true) c.JSON(http.StatusOK, gin.H{"success": true, "redirect": h.getDashboardPath() + "/home"}) return } h.rateLimiter.RecordAttempt(ip) h.recordLoginHistory(ip, userAgent, credentials.Username, "", false, "用户名或密码错误") c.JSON(http.StatusUnauthorized, gin.H{"success": false, "message": "用户名或密码错误", "remaining": remaining - 1}) } // recordLoginHistory 异步记录登录历史 // @param ip 客户端IP // @param userAgent 用户代理 // @param username 用户名 // @param displayName 显示名称 // @param success 是否登录成功 // @param message 结果消息 // @author sunct func (h *AdminHandler) recordLoginHistory(ip, userAgent, username, displayName string, success bool, message string) { go func() { history := &model.LoginHistory{ ID: uuid.NewString(), UserID: "", UserName: displayName, IP: ip, Location: "", UserAgent: userAgent, Success: success, Message: message, } resumeRepo.CreateLoginHistory(history) }() } // Captcha 生成并返回图形验证码 // @param c Gin 上下文 // @author sunct func (h *AdminHandler) Captcha(c *gin.Context) { security.ServeCaptcha(c.Writer) } // Logout 处理管理员退出登录,清除令牌和Cookie // @param c Gin 上下文 // @author sunct func (h *AdminHandler) Logout(c *gin.Context) { token, err := c.Cookie("admin_token") if err == nil && token != "" { tokensMutex.Lock() delete(adminTokens, token) tokensMutex.Unlock() SaveTokens() } c.SetCookie("admin_token", "", -1, "/", "", false, true) c.Redirect(http.StatusFound, h.getAdminPath()+"/login") } // AuthMiddleware 后台认证中间件,校验 Cookie 令牌有效性并自动续期 // @return gin.HandlerFunc Gin中间件处理函数 // @author sunct func (h *AdminHandler) AuthMiddleware() gin.HandlerFunc { return func(c *gin.Context) { if !h.config.Auth.Enabled { c.Next() return } token, err := c.Cookie("admin_token") if err != nil || token == "" { h.render401Page(c) c.Abort() return } tokensMutex.Lock() info, exists := adminTokens[token] if !exists { tokensMutex.Unlock() c.SetCookie("admin_token", "", -1, "/", "", false, true) h.render401Page(c) c.Abort() return } now := time.Now() if now.After(time.Time(info.ExpiresAt)) { delete(adminTokens, token) tokensMutex.Unlock() SaveTokens() c.SetCookie("admin_token", "", -1, "/", "", false, true) h.render401Page(c) c.Abort() return } sinceLastActivity := now.Sub(time.Time(info.LastActivity)) needSave := false info.LastActivity = localTime(now) if sinceLastActivity >= tokenRenewInterval { info.ExpiresAt = localTime(now.Add(tokenExpiryDuration)) needSave = true } adminTokens[token] = info tokensMutex.Unlock() if needSave { SaveTokens() c.SetCookie("admin_token", token, int(tokenExpiryDuration.Seconds()), "/", "", false, true) } c.Set("username", info.Username) c.Next() } } // render401Page 渲染 401 未授权错误页面 // @param c Gin 上下文 // @author sunct func (h *AdminHandler) render401Page(c *gin.Context) { t, err := template.ParseFiles("templates/admin/401.html") if err != nil { c.String(http.StatusUnauthorized, "401 Unauthorized") return } var buf bytes.Buffer err = t.Execute(&buf, gin.H{ "AdminPath": h.getAdminPath(), }) if err != nil { c.String(http.StatusUnauthorized, "401 Unauthorized") return } c.Header("Content-Type", "text/html; charset=utf-8") c.String(http.StatusUnauthorized, buf.String()) } // generateEncryptedToken 生成 HMAC-SHA256 签名的加密认证令牌 // @param username 用户名 // @return string Base64编码的令牌 // @author sunct func generateEncryptedToken(username string) string { randomBytes := make([]byte, 16) rand.Read(randomBytes) timestamp := fmt.Sprintf("%d", time.Now().UnixNano()) payload := username + ":" + timestamp + ":" + base64.URLEncoding.EncodeToString(randomBytes) h := hmac.New(sha256.New, []byte(tokenSecret)) h.Write([]byte(payload)) signature := h.Sum(nil) token := base64.URLEncoding.EncodeToString(signature) + "." + base64.URLEncoding.EncodeToString([]byte(payload)) return token } // getMenus 获取后台侧边栏固定菜单配置 // @return []*model.Menu 菜单列表 // @author sunct func (h *AdminHandler) getMenus() []*model.Menu { return []*model.Menu{ {ID: "home", Name: "后台首页", Icon: "fas fa-home", Path: "home", IsEnabled: true, SortOrder: 10}, {ID: "users", Name: "人员管理", Icon: "fas fa-users", Path: "users", IsEnabled: true, SortOrder: 20}, {ID: "resumes", Name: "简历管理", Icon: "fas fa-file-alt", Path: "resumes", IsEnabled: true, SortOrder: 25}, {ID: "portfolios", Name: "作品集", Icon: "fas fa-folder-open", Path: "portfolios", IsEnabled: true, SortOrder: 27}, {ID: "quiz", Name: "面试答题", Icon: "fas fa-question-circle", Path: "quiz", IsEnabled: true, SortOrder: 28}, {ID: "settings", Name: "系统设置", Icon: "fas fa-cog", Path: "settings", IsEnabled: true, SortOrder: 30}, } } // substr Unicode 安全的字符串截取函数,支持中文等多字节字符 // @param s 源字符串 // @param start 起始位置(负数表示从末尾倒数) // @param length 截取长度(可选,省略则截取到末尾) // @return string 截取后的子串 // @author sunct func substr(s string, start int, length ...int) string { if len(s) == 0 { return "" } runes := []rune(s) runeLen := len(runes) startIdx := 0 if start < 0 { startIdx = runeLen + start if startIdx < 0 { startIdx = 0 } } else { startIdx = start } if startIdx >= runeLen { return "" } if len(length) == 0 { return string(runes[startIdx:]) } endIdx := startIdx + length[0] if endIdx > runeLen { endIdx = runeLen } return string(runes[startIdx:endIdx]) } // renderContentTemplate 渲染内容区模板为 HTML 字符串 // @param templateName 模板文件名(不含后缀) // @param data 模板数据 // @return template.HTML 渲染后的HTML内容 // @author sunct func (h *AdminHandler) renderContentTemplate(templateName string, data interface{}) template.HTML { templatePath := "templates/admin/" + templateName + ".html" content, err := os.ReadFile(templatePath) if err != nil { log.Printf("[ERROR] Failed to read template file: %s, error: %v", templatePath, err) if h.config.Server.Debug { return template.HTML("Template load error: " + err.Error()) } return template.HTML("Template load error") } t, err := template.New(templateName).Funcs(template.FuncMap{ "substr": substr, "add": func(a, b int) int { return a + b }, "subtract": func(a, b int) int { return a - b }, "to": func(a, b int) []int { if b < a { return []int{} } r := make([]int, b-a+1) for i := range r { r[i] = a + i } return r }, "join": func(slice []string, sep string) string { result := "" for i, s := range slice { if i > 0 { result += sep } result += s } return result }, }).Parse(string(content)) if err != nil { log.Printf("[ERROR] Failed to parse template: %s, error: %v", templatePath, err) if h.config.Server.Debug { return template.HTML("Template parse error: " + err.Error()) } return template.HTML("Template parse error") } var buf bytes.Buffer if err := t.Execute(&buf, data); err != nil { log.Printf("[ERROR] Failed to execute template: %s, error: %v", templatePath, err) if h.config.Server.Debug { return template.HTML("Template render error: " + err.Error()) } return template.HTML("Template render error") } return template.HTML(buf.String()) } // renderAdminPage 渲染完整后台页面(基础布局模板 + 内容模板组合) // @param c Gin 上下文 // @param pageTitle 页面标题 // @param currentMenu 当前高亮菜单ID // @param contentTemplate 内容模板名 // @param contentData 内容模板数据 // @param extraScripts 额外注入的脚本 // @author sunct func (h *AdminHandler) renderAdminPage(c *gin.Context, pageTitle, currentMenu, contentTemplate string, contentData interface{}, extraScripts string) { content := h.renderContentTemplate(contentTemplate, contentData) t := template.New("base.html").Funcs(template.FuncMap{ "substr": substr, "add": func(a, b int) int { return a + b }, "subtract": func(a, b int) int { return a - b }, "to": func(a, b int) []int { if b < a { return []int{} } r := make([]int, b-a+1) for i := range r { r[i] = a + i } return r }, "join": func(slice []string, sep string) string { result := "" for i, s := range slice { if i > 0 { result += sep } result += s } return result }, }) t, err := t.ParseFiles("templates/admin/base.html") if err != nil { log.Printf("[ERROR] Failed to load template: %v", err) if h.config.Server.Debug { c.String(http.StatusInternalServerError, "Template load error: "+err.Error()) } else { c.String(http.StatusInternalServerError, "Template load error") } return } username, _ := c.Get("username") var buf bytes.Buffer err = t.Execute(&buf, gin.H{ "PageTitle": pageTitle, "AdminPath": h.getDashboardPath(), "Username": username, "CurrentMenu": currentMenu, "Menus": h.getMenus(), "Content": content, "ExtraScripts": extraScripts, "Config": h.loadSettings(), }) if err != nil { log.Printf("[ERROR] Failed to render template: %v", err) if h.config.Server.Debug { c.String(http.StatusInternalServerError, "Template render error: "+err.Error()) } else { c.String(http.StatusInternalServerError, "Template render error") } return } c.Header("Content-Type", "text/html; charset=utf-8") c.String(http.StatusOK, buf.String()) } // HomePage 渲染后台首页仪表盘,展示统计数据和最近动态 // @param c Gin 上下文 // @author sunct func (h *AdminHandler) HomePage(c *gin.Context) { var stats struct { TotalUsers int TotalResumes int TotalPortfolio int TotalQuizRecords int TotalFavorites int TotalWrongAnswers int } var recentUsers []*model.User var recentResumes []*model.Resume if resumeRepo != nil { users, _ := resumeRepo.GetAllUsers() stats.TotalUsers = len(users) resumes, _ := resumeRepo.GetAllResumes() stats.TotalResumes = len(resumes) portfolioItems, _ := resumeRepo.GetPortfolioItems() stats.TotalPortfolio = len(portfolioItems) allRecords := make([]*model.QuizRecord, 0) allFavorites := make([]*model.FavoriteQuestion, 0) for _, u := range users { records, _ := resumeRepo.GetQuizRecordsByUserID(u.ID) allRecords = append(allRecords, records...) favs, _ := resumeRepo.GetFavoriteQuestionsByUserID(u.ID) allFavorites = append(allFavorites, favs...) } stats.TotalQuizRecords = len(allRecords) favCount := 0 wrongCount := 0 for _, f := range allFavorites { if f.IsFavorite { favCount++ } if !f.IsCorrect { wrongCount++ } } stats.TotalFavorites = favCount stats.TotalWrongAnswers = wrongCount // 复用已查询数据,避免重复请求 recentUsers = users if len(recentUsers) > 5 { recentUsers = recentUsers[:5] } recentResumes = resumes if len(recentResumes) > 5 { recentResumes = recentResumes[:5] } } h.renderAdminPage(c, "后台首页", "home", "home", gin.H{ "Stats": stats, "RecentUsers": recentUsers, "RecentResumes": recentResumes, "AdminPath": h.getDashboardPath(), "Uptime": time.Since(h.startTime).Round(time.Second).String(), }, "") } // UsersPage 渲染人员管理列表页,支持分页 // @param c Gin 上下文 // @author sunct func (h *AdminHandler) UsersPage(c *gin.Context) { page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) if page < 1 { page = 1 } pageSize := h.getPageSize() users, total, _ := resumeRepo.GetUsersWithPagination(page, pageSize) totalPages := (total + pageSize - 1) / pageSize h.renderAdminPage(c, "人员管理", "users", "users", gin.H{ "Users": users, "Page": page, "TotalPages": totalPages, "Total": total, "AdminPath": h.getDashboardPath(), }, "") } // ResumesPage 渲染简历管理列表页,支持多条件筛选和分页 // @param c Gin 上下文 // @author sunct func (h *AdminHandler) ResumesPage(c *gin.Context) { page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) if page < 1 { page = 1 } pageSize := h.getPageSize() // 筛选参数 userID := c.Query("user_id") keyword := c.Query("keyword") template := c.Query("template") showInHomeStr := c.Query("show_in_home") var showInHome *bool if showInHomeStr == "1" { t := true showInHome = &t } else if showInHomeStr == "0" { f := false showInHome = &f } resumes, total, _ := resumeRepo.GetResumesWithFilter(page, pageSize, userID, keyword, template, showInHome) totalPages := (total + pageSize - 1) / pageSize users, _ := resumeRepo.GetAllUsers() userMap := make(map[string]*model.User) for _, u := range users { userMap[u.ID] = u } // 构建分页链接需要保留的筛选查询字符串前缀(不含 page) filterQuery := "" if userID != "" { filterQuery += "&user_id=" + userID } if keyword != "" { filterQuery += "&keyword=" + keyword } if template != "" { filterQuery += "&template=" + template } if showInHomeStr != "" { filterQuery += "&show_in_home=" + showInHomeStr } h.renderAdminPage(c, "简历管理", "resumes", "resumes", gin.H{ "Resumes": resumes, "Users": users, "UserMap": userMap, "Page": page, "TotalPages": totalPages, "Total": total, "AdminPath": h.getDashboardPath(), "FilterUserID": userID, "FilterKeyword": keyword, "FilterTemplate": template, "FilterShowInHome": showInHomeStr, "FilterQuery": filterQuery, }, "") } // PortfoliosPage 渲染作品集项目列表页,支持分页 // @param c Gin 上下文 // @author sunct func (h *AdminHandler) PortfoliosPage(c *gin.Context) { page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) if page < 1 { page = 1 } pageSize := h.getPageSize() items, total, _ := resumeRepo.GetPortfolioItemsWithPagination(page, pageSize) totalPages := (total + pageSize - 1) / pageSize users, _ := resumeRepo.GetAllUsers() userMap := make(map[string]*model.User) for _, u := range users { userMap[u.ID] = u } h.renderAdminPage(c, "作品集", "portfolios", "portfolios", gin.H{ "Items": items, "Users": users, "UserMap": userMap, "Page": page, "TotalPages": totalPages, "Total": total, "AdminPath": h.getDashboardPath(), }, "") } // PortfolioAddPage 渲染新增作品集项目表单页 // @param c Gin 上下文 // @author sunct func (h *AdminHandler) PortfolioAddPage(c *gin.Context) { users, _ := resumeRepo.GetAllUsers() resumes, _, _ := resumeRepo.GetResumesWithPagination(1, 100) h.renderAdminPage(c, "新增项目", "portfolios", "portfolio-edit", gin.H{ "Item": &model.PortfolioItem{}, "Users": users, "Resumes": resumes, "AdminPath": h.getDashboardPath(), }, "") } // PortfolioEditPage 渲染编辑作品集项目表单页 // @param c Gin 上下文 // @author sunct func (h *AdminHandler) PortfolioEditPage(c *gin.Context) { id, _ := strconv.ParseUint(c.Param("id"), 10, 64) item, _ := resumeRepo.GetPortfolioItem(uint(id)) users, _ := resumeRepo.GetAllUsers() resumes, _, _ := resumeRepo.GetResumesWithPagination(1, 100) if item == nil { c.String(http.StatusNotFound, "项目不存在") return } h.renderAdminPage(c, "编辑项目", "portfolios", "portfolio-edit", gin.H{ "Item": item, "Users": users, "Resumes": resumes, "AdminPath": h.getDashboardPath(), }, "") } // QuizPage 渲染面试答题总览页,展示所有用户的答题统计 // @param c Gin 上下文 // @author sunct func (h *AdminHandler) QuizPage(c *gin.Context) { page, _ := strconv.Atoi(c.Query("page")) if page < 1 { page = 1 } pageSize := h.getPageSize() users, totalRecords, _ := resumeRepo.GetUsersWithPagination(page, pageSize) type UserQuizStats struct { User *model.User TotalAttempts int CorrectRate int FavoriteCount int WrongCount int TotalScore float64 AvgScore float64 LastAttemptDate string } var usersWithStats []UserQuizStats var totalStats struct { TotalAttempts int TotalFavorites int TotalWrong int } for _, user := range users { quizRecords, _ := resumeRepo.GetQuizRecordsByUserID(user.ID) favoriteQuestions, _ := resumeRepo.GetFavoriteQuestionsByUserID(user.ID) totalAttempts := len(quizRecords) totalCorrect := 0 totalQuestions := 0 totalScore := 0.0 lastAttemptDate := "" favoriteCount := 0 wrongCount := 0 for _, record := range quizRecords { totalCorrect += record.CorrectCount totalQuestions += record.TotalCount totalScore += record.Score if record.CreatedAt > lastAttemptDate { lastAttemptDate = record.CreatedAt } } for _, fq := range favoriteQuestions { if fq.IsFavorite { favoriteCount++ } if !fq.IsCorrect { wrongCount++ } } totalStats.TotalAttempts += totalAttempts totalStats.TotalFavorites += favoriteCount totalStats.TotalWrong += wrongCount correctRate := 0 if totalQuestions > 0 { correctRate = int(float64(totalCorrect) / float64(totalQuestions) * 100) } avgScore := 0.0 if totalAttempts > 0 { avgScore = totalScore / float64(totalAttempts) } usersWithStats = append(usersWithStats, UserQuizStats{ User: user, TotalAttempts: totalAttempts, CorrectRate: correctRate, FavoriteCount: favoriteCount, WrongCount: wrongCount, TotalScore: totalScore, AvgScore: avgScore, LastAttemptDate: lastAttemptDate, }) } totalPages := (totalRecords + pageSize - 1) / pageSize h.renderAdminPage(c, "面试答题", "quiz", "quiz", gin.H{ "UsersWithStats": usersWithStats, "TotalStats": totalStats, "TotalRecords": totalRecords, "TotalPages": totalPages, "CurrentPage": page, "PageSize": pageSize, "AdminPath": h.getDashboardPath(), }, "") } // QuizGeneratePage 渲染 AI 生成题目配置页 // @param c Gin 上下文 // @author sunct func (h *AdminHandler) QuizGeneratePage(c *gin.Context) { userID := c.Param("id") user, _ := resumeRepo.GetUserByID(userID) resumes, _ := resumeRepo.GetResumesByUserID(userID) h.renderAdminPage(c, "AI生成题目", "quiz", "quiz-generate", gin.H{ "User": user, "Resumes": resumes, "AdminPath": h.getDashboardPath(), }, "") } // UserResumePage 渲染单个用户的简历管理页 // @param c Gin 上下文 // @author sunct func (h *AdminHandler) UserResumePage(c *gin.Context) { userID := c.Param("id") user, _ := resumeRepo.GetUserByID(userID) resumes, _ := resumeRepo.GetResumesByUserID(userID) h.renderAdminPage(c, "个人简历", "resumes", "user-resume", gin.H{ "User": user, "Resumes": resumes, "AdminPath": h.getDashboardPath(), }, "") } // ResumeAddPage 渲染添加简历表单页(不绑定用户) // @param c Gin 上下文 // @author sunct func (h *AdminHandler) ResumeAddPage(c *gin.Context) { resume := &model.Resume{ ShowInHome: true, } h.renderAdminPage(c, "添加简历", "resumes", "resume-edit", gin.H{ "User": nil, "Resume": resume, "IsNew": true, "AdminPath": h.getDashboardPath(), }, "") } // ResumeEditPageWithoutUser 渲染无用户绑定的简历编辑页 // @param c Gin 上下文 // @author sunct func (h *AdminHandler) ResumeEditPageWithoutUser(c *gin.Context) { resumeID := c.Param("id") resume, _ := resumeRepo.GetResume(resumeID) if resume == nil { c.Redirect(http.StatusMovedPermanently, h.getDashboardPath()+"/resumes") return } h.renderAdminPage(c, "编辑简历", "resumes", "resume-edit", gin.H{ "User": nil, "Resume": resume, "IsNew": false, "AdminPath": h.getDashboardPath(), }, "") } // ResumeEditPage 渲染完整字段的简历编辑页(支持新建和编辑) // @param c Gin 上下文 // @author sunct func (h *AdminHandler) ResumeEditPage(c *gin.Context) { userID := c.Param("id") resumeID := c.Param("resume_id") user, _ := resumeRepo.GetUserByID(userID) if user == nil { c.Redirect(http.StatusMovedPermanently, h.getDashboardPath()+"/resumes") return } var resume *model.Resume var isNew bool if resumeID == "new" { isNew = true resume = &model.Resume{ UserID: userID, ShowInHome: true, BasicInfo: model.BasicInfo{ Name: user.Name, Email: user.Email, Phone: user.Phone, }, } } else { isNew = false resume, _ = resumeRepo.GetResume(resumeID) if resume == nil { resume = &model.Resume{ UserID: userID, ShowInHome: true, BasicInfo: model.BasicInfo{ Name: user.Name, Email: user.Email, Phone: user.Phone, }, } } } h.renderAdminPage(c, "编辑简历", "resumes", "resume-edit", gin.H{ "User": user, "Resume": resume, "IsNew": isNew, "AdminPath": h.getDashboardPath(), }, "") } // UserPortfolioPage 渲染单个用户的作品集管理页 // @param c Gin 上下文 // @author sunct func (h *AdminHandler) UserPortfolioPage(c *gin.Context) { userID := c.Param("id") user, _ := resumeRepo.GetUserByID(userID) portfolioItems, _ := resumeRepo.GetPortfolioItemsByUserID(userID) h.renderAdminPage(c, "作品集管理", "portfolios", "user-portfolio", gin.H{ "User": user, "PortfolioItems": portfolioItems, "AdminPath": h.getDashboardPath(), }, "") } // UserQuizPage 渲染单个用户的面试题库页,含答题记录和统计 // @param c Gin 上下文 // @author sunct func (h *AdminHandler) UserQuizPage(c *gin.Context) { userID := c.Param("id") page := parseInt(c.DefaultQuery("page", "1"), 1) pageSize := 10 user, _ := resumeRepo.GetUserByID(userID) resumes, _ := resumeRepo.GetResumesByUserID(userID) quizRecords, totalRecords, _ := resumeRepo.GetQuizRecordsByUserIDWithPagination(userID, page, pageSize) var favoriteCount, wrongCount int var totalScore float64 var avgScore int favoriteQuestions, _ := resumeRepo.GetFavoriteQuestionsByUserID(userID) for _, fq := range favoriteQuestions { if fq.IsFavorite { favoriteCount++ } if !fq.IsCorrect { wrongCount++ } } allRecords, _ := resumeRepo.GetQuizRecordsByUserID(userID) for _, r := range allRecords { totalScore += r.Score } if len(allRecords) > 0 { avgScore = int(totalScore / float64(len(allRecords))) } totalPages := (totalRecords + pageSize - 1) / pageSize h.renderAdminPage(c, "面试题库", "quiz", "user-quiz", gin.H{ "User": user, "Resumes": resumes, "QuizRecords": quizRecords, "TotalRecords": totalRecords, "TotalPages": totalPages, "CurrentPage": page, "PageSize": pageSize, "FavoriteQuestions": favoriteQuestions, "FavoriteCount": favoriteCount, "WrongCount": wrongCount, "AvgScore": avgScore, "AdminPath": h.getDashboardPath(), }, "") } // QuizRecordDetailPage 渲染单条答题记录详情页,展示题目和答案 // @param c Gin 上下文 // @author sunct func (h *AdminHandler) QuizRecordDetailPage(c *gin.Context) { userID := c.Param("id") recordID := c.Param("record_id") user, _ := resumeRepo.GetUserByID(userID) record, _ := resumeRepo.GetQuizRecord(recordID) if user == nil || record == nil { c.String(http.StatusNotFound, "记录不存在") return } type questionWithCorrect struct { *model.QuizQuestion IsCorrect bool } questions := make([]*model.QuizQuestion, 0, len(record.Questions)) correctList := make([]bool, 0, len(record.Questions)) userAnswers := make([]string, 0, len(record.Questions)) for i, q := range record.Questions { questions = append(questions, &q) userAns := "" if record.UserAnswers != nil { userAns = record.UserAnswers[strconv.Itoa(i)] } userAnswers = append(userAnswers, userAns) isCorrect := false if q.Type == "mcq" || q.Type == "fill" { isCorrect = userAns != "" && q.Answer != "" && strings.EqualFold(strings.TrimSpace(userAns), strings.TrimSpace(q.Answer)) } else if len(userAns) > 0 { isCorrect = true } correctList = append(correctList, isCorrect) } h.renderAdminPage(c, "答题详情", "quiz", "quiz-record-detail", gin.H{ "User": user, "Record": record, "Questions": questions, "Correct": correctList, "UserAnswers": userAnswers, "AdminPath": h.getDashboardPath(), }, "") } // ToggleFavoriteAPI 切换题目收藏状态 API // @param c Gin 上下文 // @author sunct func (h *AdminHandler) ToggleFavoriteAPI(c *gin.Context) { id := c.Param("id") err := resumeRepo.ToggleFavorite(id) if err != nil { Error(c, err.Error()) return } OK(c, nil) } // DashboardNotFound 渲染后台 404 页面 // @param c Gin 上下文 // @author sunct func (h *AdminHandler) DashboardNotFound(c *gin.Context) { c.Status(http.StatusNotFound) c.File("templates/admin/404.html") } // SettingsPage 渲染系统设置页 // @param c Gin 上下文 // @author sunct func (h *AdminHandler) SettingsPage(c *gin.Context) { config := h.loadSettings() h.renderAdminPage(c, "系统设置", "settings", "settings", gin.H{ "Config": config, "AdminPath": h.getDashboardPath(), }, "") } // loadSettings 从数据库加载系统配置,不存在则返回默认值 // @return *model.SystemConfig 系统配置 // @author sunct func (h *AdminHandler) loadSettings() *model.SystemConfig { config, err := resumeRepo.GetSystemConfig() if err != nil || config == nil { return &model.SystemConfig{ ID: "default", WebsiteDomain: "http://localhost:8090", WebsiteTitle: "Resume Platform", AdminPageSize: 20, } } if config.AdminPageSize <= 0 { config.AdminPageSize = 20 } return config } // getPageSize 获取后台列表分页大小 // @return int 每页条数 // @author sunct func (h *AdminHandler) getPageSize() int { config := h.loadSettings() if config.AdminPageSize <= 0 { return 20 } return config.AdminPageSize } // saveSettings 保存系统配置,确保始终只有一条记录(先清空再写入) // @param config 系统配置 // @return error 保存错误 // @author sunct func (h *AdminHandler) saveSettings(config *model.SystemConfig) error { config.ID = "default" existingConfig, err := resumeRepo.GetSystemConfig() if err != nil { return resumeRepo.CreateSystemConfig(config) } resumeRepo.CleanSystemConfig() config.CreatedAt = existingConfig.CreatedAt return resumeRepo.UpdateSystemConfig(config.ID, config) } // GetSettingsAPI 获取系统配置 API // @param c Gin 上下文 // @author sunct func (h *AdminHandler) GetSettingsAPI(c *gin.Context) { config := h.loadSettings() OK(c, config) } // UpdateSettingsAPI 更新系统配置 API,支持 Base64 格式 Logo 图片上传 // @param c Gin 上下文 // @author sunct func (h *AdminHandler) UpdateSettingsAPI(c *gin.Context) { var config model.SystemConfig if err := c.ShouldBindJSON(&config); err != nil { BadRequest(c, "参数错误") return } if config.LogoPath != "" && strings.HasPrefix(config.LogoPath, "data:image/") { parts := strings.SplitN(config.LogoPath, ",", 2) if len(parts) == 2 { header := parts[0] data := parts[1] var ext string switch { case strings.Contains(header, "image/png"): ext = ".png" case strings.Contains(header, "image/jpeg"): ext = ".jpg" case strings.Contains(header, "image/jpg"): ext = ".jpg" case strings.Contains(header, "image/gif"): ext = ".gif" case strings.Contains(header, "image/webp"): ext = ".webp" default: ext = ".png" } logoPath := "./static/images/logo" + ext if err := os.MkdirAll("./static/images", 0755); err != nil { Error(c, "创建目录失败") return } decoded, err := base64.StdEncoding.DecodeString(data) if err != nil { BadRequest(c, "图片解码失败") return } if err := os.WriteFile(logoPath, decoded, 0644); err != nil { Error(c, "保存图片失败") return } config.LogoPath = "/static/images/logo" + ext } } if err := h.saveSettings(&config); err != nil { Error(c, "保存失败") return } Success(c, "设置保存成功") } // SystemInfoAPI 获取系统运行信息 API(Go版本、系统、运行时长等) // @param c Gin 上下文 // @author sunct func (h *AdminHandler) SystemInfoAPI(c *gin.Context) { OK(c, map[string]interface{}{ "go_version": runtime.Version(), "os": runtime.GOOS, "arch": runtime.GOARCH, "start_time": h.startTime.Format("2006-01-02 15:04:05"), "uptime": formatUptime(time.Since(h.startTime)), }) } // LoginHistoryAPI 获取登录历史列表 API,支持分页 // @param c Gin 上下文 // @author sunct func (h *AdminHandler) LoginHistoryAPI(c *gin.Context) { page := parseInt(c.Query("page"), 1) pageSize := parseInt(c.Query("pageSize"), 10) histories, total, err := resumeRepo.GetLoginHistory(page, pageSize) if err != nil { Error(c, "获取失败") return } OK(c, map[string]interface{}{ "list": histories, "total": total, }) } // formatUptime 将时长格式化为中文友好显示(天/小时/分/秒) // @param d 时间间隔 // @return string 格式化后的时长字符串 // @author sunct func formatUptime(d time.Duration) string { days := int(d.Hours() / 24) hours := int(d.Hours()) % 24 minutes := int(d.Minutes()) % 60 seconds := int(d.Seconds()) % 60 if days > 0 { return fmt.Sprintf("%d天 %d小时 %d分", days, hours, minutes) } else if hours > 0 { return fmt.Sprintf("%d小时 %d分 %d秒", hours, minutes, seconds) } else if minutes > 0 { return fmt.Sprintf("%d分 %d秒", minutes, seconds) } else { return fmt.Sprintf("%d秒", seconds) } } // UploadLogoAPI 上传网站 Logo 图片 API // @param c Gin 上下文 // @author sunct func (h *AdminHandler) UploadLogoAPI(c *gin.Context) { file, err := c.FormFile("logo") if err != nil { BadRequest(c, "请选择文件") return } ext := "" for i := len(file.Filename) - 1; i >= 0; i-- { if file.Filename[i] == '.' { ext = file.Filename[i:] break } } if ext == "" { BadRequest(c, "无效的文件格式") return } logoPath := "./static/images/logo" + ext if err := os.MkdirAll("./static/images", 0755); err != nil { Error(c, "创建目录失败") return } if err := c.SaveUploadedFile(file, logoPath); err != nil { Error(c, "保存文件失败") return } config := h.loadSettings() config.LogoPath = "/static/images/logo" + ext h.saveSettings(config) OK(c, gin.H{"logo_path": config.LogoPath}, "上传成功") } // SearchAPI 后台全局搜索 API,支持用户/简历/作品集三类搜索 // @param c Gin 上下文 // @author sunct func (h *AdminHandler) SearchAPI(c *gin.Context) { query := c.Query("q") searchType := c.DefaultQuery("type", "all") if query == "" { OK(c, []interface{}{}) return } var results []interface{} if searchType == "all" || searchType == "user" { users, _ := resumeRepo.GetAllUsers() for _, user := range users { if containsIgnoreCase(user.Name, query) || containsIgnoreCase(user.Email, query) || containsIgnoreCase(user.Username, query) { results = append(results, map[string]interface{}{ "type": "user", "title": user.Name, "subtitle": user.Email, "url": h.getDashboardPath() + "/user/" + user.ID + "/resume", }) } } } if searchType == "all" || searchType == "resume" { resumes, _ := resumeRepo.GetAllResumes() for _, resume := range resumes { if containsIgnoreCase(resume.BasicInfo.Name, query) || containsIgnoreCase(resume.BasicInfo.Title, query) || containsIgnoreCase(resume.Route, query) { results = append(results, map[string]interface{}{ "type": "resume", "title": resume.BasicInfo.Name + " - " + resume.BasicInfo.Title, "subtitle": resume.Route, "url": h.getDashboardPath() + "/user/" + resume.UserID + "/resume/" + resume.ID + "/edit", }) } } } if searchType == "all" || searchType == "portfolio" { items, _ := resumeRepo.GetPortfolioItems() for _, item := range items { if containsIgnoreCase(item.Name, query) || containsIgnoreCase(item.Description, query) { results = append(results, map[string]interface{}{ "type": "portfolio", "title": item.Name, "subtitle": item.Description, "url": h.getDashboardPath() + "/portfolio/" + strconv.FormatUint(uint64(item.ID), 10) + "/edit", }) } } } OK(c, results) } // containsIgnoreCase 忽略大小写判断字符串是否包含子串 // @param str 源字符串 // @param substr 子串 // @return bool 是否包含 // @author sunct func containsIgnoreCase(str, substr string) bool { for i := 0; i <= len(str)-len(substr); i++ { if strings.EqualFold(str[i:i+len(substr)], substr) { return true } } return false } // GetNotificationsAPI 获取系统通知列表 API // @param c Gin 上下文 // @author sunct func (h *AdminHandler) GetNotificationsAPI(c *gin.Context) { notifications, err := resumeRepo.GetNotifications() if err != nil { OK(c, []interface{}{}) return } if notifications == nil { notifications = []*model.Notification{} } type notificationResp struct { ID string `json:"id"` Type string `json:"type"` Title string `json:"title"` Message string `json:"message"` Time string `json:"time"` Read bool `json:"read"` URL string `json:"url"` } result := make([]notificationResp, 0, len(notifications)) for _, n := range notifications { result = append(result, notificationResp{ ID: n.ID, Type: n.Type, Title: n.Title, Message: n.Message, Time: formatRelativeTime(n.CreatedAt), Read: n.IsRead, URL: n.URL, }) } OK(c, result) } // MarkNotificationAsReadAPI 标记单条通知为已读 API // @param c Gin 上下文 // @author sunct func (h *AdminHandler) MarkNotificationAsReadAPI(c *gin.Context) { id := c.Param("id") if err := resumeRepo.MarkNotificationAsRead(id); err != nil { Error(c, err.Error()) return } Success(c, "标记成功") } // MarkAllNotificationsAsReadAPI 批量标记所有通知为已读 API // @param c Gin 上下文 // @author sunct func (h *AdminHandler) MarkAllNotificationsAsReadAPI(c *gin.Context) { if err := resumeRepo.MarkAllNotificationsAsRead(); err != nil { Error(c, err.Error()) return } Success(c, "标记成功") } // DeleteAllNotificationsAPI 删除所有通知 API // @param c Gin 上下文 // @author sunct func (h *AdminHandler) DeleteAllNotificationsAPI(c *gin.Context) { if err := resumeRepo.DeleteAllNotifications(); err != nil { Error(c, err.Error()) return } Success(c, "删除成功") } // GetUnreadNotificationCountAPI 获取未读通知数量 API // @param c Gin 上下文 // @author sunct func (h *AdminHandler) GetUnreadNotificationCountAPI(c *gin.Context) { count, _ := resumeRepo.GetUnreadNotificationCount() OK(c, map[string]interface{}{"count": count}) } // formatRelativeTime 将时间字符串格式化为相对时间描述(刚刚/X分钟前/昨天等) // @param t 时间字符串 // @return string 相对时间描述 // @author sunct func formatRelativeTime(t string) string { if t == "" { return "" } t = strings.TrimSpace(t) layouts := []string{"2006-01-02 15:04:05", "2006-01-02T15:04:05", "2006-01-02 15:04", time.RFC3339} var nt time.Time var err error for _, layout := range layouts { nt, err = time.Parse(layout, t) if err == nil { break } } if err != nil { return t } diff := time.Since(nt) switch { case diff < time.Minute: return "刚刚" case diff < time.Hour: return fmt.Sprintf("%d分钟前", int(diff.Minutes())) case diff < 24*time.Hour: return fmt.Sprintf("%d小时前", int(diff.Hours())) case diff < 48*time.Hour: return "昨天" case diff < 30*24*time.Hour: return fmt.Sprintf("%d天前", int(diff.Hours()/24)) default: return nt.Format("2006-01-02") } }