Files
resume/internal/handler/admin.go
T

1621 lines
42 KiB
Go

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")
}
}