1621 lines
42 KiB
Go
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")
|
|
}
|
|
}
|