Add files via upload

This commit is contained in:
QWQLwToo
2026-01-28 13:25:18 +08:00
committed by GitHub
parent 02f66ec447
commit 7030903eb6
77 changed files with 21573 additions and 0 deletions
+314
View File
@@ -0,0 +1,314 @@
package api
import (
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"home-vue-go/internal/config"
"home-vue-go/internal/database"
"home-vue-go/internal/ent/user"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
"golang.org/x/crypto/bcrypt"
)
type LoginRequest struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
}
type Claims struct {
Username string `json:"username"`
jwt.RegisteredClaims
}
func Login(db *database.Database, cfg *config.Config) gin.HandlerFunc {
return func(c *gin.Context) {
var req LoginRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "用户名和密码不能为空"})
return
}
ctx := c.Request.Context()
user, err := db.Client.User.Query().Where(user.UsernameEQ(req.Username)).First(ctx)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "用户名或密码错误"})
return
}
// 验证密码
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.Password)); err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "用户名或密码错误"})
return
}
// 获取登录IP和用户代理
ip := c.ClientIP()
userAgent := c.GetHeader("User-Agent")
// 查询IP地理位置
location := queryIPLocation(ip)
// 记录登录历史到内存
addLoginHistory(user.Username, ip, location, userAgent, true)
// 生成JWT token
claims := Claims{
Username: user.Username,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)),
IssuedAt: jwt.NewNumericDate(time.Now()),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
tokenString, err := token.SignedString([]byte(cfg.JWTSecret))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "生成token失败"})
return
}
c.JSON(http.StatusOK, gin.H{
"token": tokenString,
})
}
}
func ChangePassword(db *database.Database) gin.HandlerFunc {
return func(c *gin.Context) {
var req struct {
OldPassword string `json:"oldPassword" binding:"required"`
NewPassword string `json:"newPassword" binding:"required,min=8"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "旧密码和新密码不能为空,且新密码至少8位"})
return
}
// 从JWT中获取用户名
username, exists := c.Get("username")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "未找到用户信息"})
return
}
usernameStr := username.(string)
ctx := c.Request.Context()
// 查询用户
user, err := db.Client.User.Query().Where(user.UsernameEQ(usernameStr)).First(ctx)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "用户不存在"})
return
}
// 验证旧密码
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.OldPassword)); err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "旧密码错误"})
return
}
// 加密新密码
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "密码加密失败"})
return
}
// 更新密码到数据库
updatedUser, err := db.Client.User.UpdateOneID(user.ID).SetPassword(string(hashedPassword)).Save(ctx)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "密码更新失败: " + err.Error()})
return
}
// 验证密码已保存(可选,用于调试)
if updatedUser == nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "密码更新失败: 未返回更新后的用户"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "密码修改成功"})
}
}
func JWTAuthMiddleware(secret string) gin.HandlerFunc {
return func(c *gin.Context) {
tokenString := c.GetHeader("Authorization")
if tokenString == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "未提供认证token"})
c.Abort()
return
}
// 移除 "Bearer " 前缀
if len(tokenString) > 7 && tokenString[:7] == "Bearer " {
tokenString = tokenString[7:]
}
claims := &Claims{}
token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) {
return []byte(secret), nil
})
if err != nil || !token.Valid {
// Token失效时,记录登录IP(用于统计)
ip := c.ClientIP()
userAgent := c.GetHeader("User-Agent")
location := queryIPLocation(ip)
// 记录为失败的登录尝试(token失效)
addLoginHistory("", ip, location, userAgent, false)
c.JSON(http.StatusUnauthorized, gin.H{"error": "无效的token"})
c.Abort()
return
}
c.Set("username", claims.Username)
c.Next()
}
}
// 查询IP地理位置(使用多个公共API)
func queryIPLocation(ip string) string {
if ip == "" || ip == "::1" || ip == "127.0.0.1" {
return "本地"
}
// 尝试多个IP查询服务
apis := []struct {
name string
url string
}{
{"ipapi", fmt.Sprintf("http://ip-api.com/json/%s?lang=zh-CN", ip)},
{"ipapi.co", fmt.Sprintf("https://ipapi.co/%s/json/", ip)},
{"ip.sb", fmt.Sprintf("https://api.ip.sb/geoip/%s", ip)},
}
for _, api := range apis {
if location := queryIPFromAPI(api.url, api.name); location != "" {
return location
}
}
return "未知"
}
func queryIPFromAPI(url, apiName string) string {
client := &http.Client{Timeout: 3 * time.Second}
resp, err := client.Get(url)
if err != nil {
return ""
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return ""
}
var result map[string]interface{}
if err := json.Unmarshal(body, &result); err != nil {
return ""
}
switch apiName {
case "ipapi":
if country, ok := result["country"].(string); ok {
region := ""
if r, ok := result["regionName"].(string); ok {
region = r
}
city := ""
if c, ok := result["city"].(string); ok {
city = c
}
parts := []string{country}
if region != "" {
parts = append(parts, region)
}
if city != "" {
parts = append(parts, city)
}
return strings.Join(parts, " ")
}
case "ipapi.co":
if country, ok := result["country_name"].(string); ok {
city := ""
if c, ok := result["city"].(string); ok {
city = c
}
if city != "" {
return fmt.Sprintf("%s %s", country, city)
}
return country
}
case "ip.sb":
if country, ok := result["country"].(string); ok {
city := ""
if c, ok := result["city"].(string); ok {
city = c
}
if city != "" {
return fmt.Sprintf("%s %s", country, city)
}
return country
}
}
return ""
}
// 获取登录历史
func GetLoginHistory(db *database.Database) gin.HandlerFunc {
return func(c *gin.Context) {
limit := c.DefaultQuery("limit", "20")
limitInt := 20
if n, err := parseInt(limit); err == nil && n > 0 {
limitInt = n
if limitInt > 100 {
limitInt = 100
}
}
// 从内存获取登录历史
histories := getLoginHistoryRecords(limitInt)
result := make([]gin.H, 0, len(histories))
for _, h := range histories {
result = append(result, gin.H{
"username": h.Username,
"ip": h.IP,
"location": h.Location,
"userAgent": h.UserAgent,
"loginTime": h.LoginTime.Format("2006-01-02 15:04:05"),
"success": h.Success,
})
}
c.JSON(http.StatusOK, gin.H{
"data": result,
"count": len(result),
})
}
}
// 简单的整数解析
func parseInt(s string) (int, error) {
var n int
for _, char := range s {
if char >= '0' && char <= '9' {
n = n*10 + int(char-'0')
} else {
return 0, fmt.Errorf("invalid number")
}
}
return n, nil
}
+279
View File
@@ -0,0 +1,279 @@
package api
import (
"net/http"
"home-vue-go/internal/config"
"home-vue-go/internal/database"
"github.com/gin-gonic/gin"
)
// GetSiteConfig 获取站点配置(含底部年份配置)
func GetSiteConfig(db *database.Database, cfg *config.Config) gin.HandlerFunc {
return func(c *gin.Context) {
ctx := c.Request.Context()
footerCfg, _ := cfg.LoadFooterYear()
visitTimerCfg, _ := cfg.LoadVisitTimer()
siteCfg, err := db.Client.SiteConfig.Get(ctx, 1)
if err != nil {
// 如果不存在,返回默认值 + 年份配置
c.JSON(http.StatusOK, gin.H{
"siteName": "个人主页",
"siteURL": "https://example.com",
"siteIcon": "/favicon.ico",
"siteDescription": "一个基于Vue3的个人主页",
"siteKeywords": "个人主页,Vue3",
"userName": "用户",
"profileImageURL": "",
"icpNumber": "暂未填写",
"policeNumber": "暂未填写",
"pageTitle": "个人主页",
"favicon": "/favicon.ico",
"umamiScript": "",
"umamiWebsiteId": "",
"iconLibrary": "//lib.baomitu.com/font-awesome/6.5.0/css/all.min.css",
"fontLibrary": "",
"showVisitTimer": visitTimerCfg.ShowVisitTimer,
"footerYearStart": footerCfg.Start,
"footerYearEnd": footerCfg.End,
})
return
}
c.JSON(http.StatusOK, gin.H{
"siteName": siteCfg.SiteName,
"siteURL": siteCfg.SiteURL,
"siteIcon": siteCfg.SiteIcon,
"siteDescription": siteCfg.SiteDescription,
"siteKeywords": siteCfg.SiteKeywords,
"userName": siteCfg.UserName,
"profileImageURL": siteCfg.ProfileImageURL,
"icpNumber": siteCfg.IcpNumber,
"policeNumber": siteCfg.PoliceNumber,
"pageTitle": siteCfg.PageTitle,
"favicon": siteCfg.Favicon,
"umamiScript": siteCfg.UmamiScript,
"umamiWebsiteId": siteCfg.UmamiWebsiteID,
"iconLibrary": siteCfg.IconLibrary,
"fontLibrary": siteCfg.FontLibrary,
"showVisitTimer": visitTimerCfg.ShowVisitTimer,
"footerYearStart": footerCfg.Start,
"footerYearEnd": footerCfg.End,
})
}
}
// UpdateSiteConfig 更新站点配置(含底部年份配置)
func UpdateSiteConfig(db *database.Database, cfg *config.Config) gin.HandlerFunc {
return func(c *gin.Context) {
var req struct {
SiteName string `json:"siteName"`
SiteURL string `json:"siteURL"`
SiteIcon string `json:"siteIcon"`
SiteDescription string `json:"siteDescription"`
SiteKeywords string `json:"siteKeywords"`
UserName string `json:"userName"`
ProfileImageURL string `json:"profileImageURL"`
ICPNumber string `json:"icpNumber"`
PoliceNumber string `json:"policeNumber"`
PageTitle string `json:"pageTitle"`
Favicon string `json:"favicon"`
UmamiScript string `json:"umamiScript"`
UmamiWebsiteId string `json:"umamiWebsiteId"`
IconLibrary string `json:"iconLibrary"`
FontLibrary string `json:"fontLibrary"`
ShowVisitTimer *bool `json:"showVisitTimer"`
FooterYearStart string `json:"footerYearStart"`
FooterYearEnd string `json:"footerYearEnd"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
ctx := c.Request.Context()
update := db.Client.SiteConfig.UpdateOneID(1)
if req.SiteName != "" {
update.SetSiteName(req.SiteName)
}
if req.SiteURL != "" {
update.SetSiteURL(req.SiteURL)
}
if req.SiteIcon != "" {
update.SetSiteIcon(req.SiteIcon)
}
if req.SiteDescription != "" {
update.SetSiteDescription(req.SiteDescription)
}
if req.SiteKeywords != "" {
update.SetSiteKeywords(req.SiteKeywords)
}
if req.UserName != "" {
update.SetUserName(req.UserName)
}
if req.ProfileImageURL != "" {
update.SetProfileImageURL(req.ProfileImageURL)
}
if req.ICPNumber != "" {
update.SetIcpNumber(req.ICPNumber)
}
if req.PoliceNumber != "" {
update.SetPoliceNumber(req.PoliceNumber)
}
if req.PageTitle != "" {
update.SetPageTitle(req.PageTitle)
}
if req.Favicon != "" {
update.SetFavicon(req.Favicon)
}
if req.UmamiScript != "" {
update.SetUmamiScript(req.UmamiScript)
}
if req.UmamiWebsiteId != "" {
update.SetUmamiWebsiteID(req.UmamiWebsiteId)
}
if req.IconLibrary != "" {
update.SetIconLibrary(req.IconLibrary)
}
if req.FontLibrary != "" {
update.SetFontLibrary(req.FontLibrary)
}
siteCfg, err := update.Save(ctx)
if err != nil {
// 如果不存在,创建新的
create := db.Client.SiteConfig.Create().
SetSiteName(req.SiteName).
SetSiteURL(req.SiteURL).
SetSiteIcon(req.SiteIcon).
SetSiteDescription(req.SiteDescription).
SetSiteKeywords(req.SiteKeywords).
SetUserName(req.UserName)
if req.ProfileImageURL != "" {
create.SetProfileImageURL(req.ProfileImageURL)
}
if req.ICPNumber != "" {
create.SetIcpNumber(req.ICPNumber)
}
if req.PoliceNumber != "" {
create.SetPoliceNumber(req.PoliceNumber)
}
if req.PageTitle != "" {
create.SetPageTitle(req.PageTitle)
}
if req.Favicon != "" {
create.SetFavicon(req.Favicon)
}
if req.UmamiScript != "" {
create.SetUmamiScript(req.UmamiScript)
}
if req.UmamiWebsiteId != "" {
create.SetUmamiWebsiteID(req.UmamiWebsiteId)
}
if req.IconLibrary != "" {
create.SetIconLibrary(req.IconLibrary)
}
if req.FontLibrary != "" {
create.SetFontLibrary(req.FontLibrary)
}
siteCfg, err = create.Save(ctx)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
}
// 保存底部年份配置到独立文件
_ = cfg.SaveFooterYear(&config.FooterYearConfig{
Start: req.FooterYearStart,
End: req.FooterYearEnd,
})
// 保存访问时间显示配置
if req.ShowVisitTimer != nil {
_ = cfg.SaveVisitTimer(&config.VisitTimerConfig{
ShowVisitTimer: *req.ShowVisitTimer,
})
}
// 读取最新的配置用于返回
footerCfg, _ := cfg.LoadFooterYear()
visitTimerCfg, _ := cfg.LoadVisitTimer()
c.JSON(http.StatusOK, gin.H{
"siteName": siteCfg.SiteName,
"siteURL": siteCfg.SiteURL,
"siteIcon": siteCfg.SiteIcon,
"siteDescription": siteCfg.SiteDescription,
"siteKeywords": siteCfg.SiteKeywords,
"userName": siteCfg.UserName,
"profileImageURL": siteCfg.ProfileImageURL,
"icpNumber": siteCfg.IcpNumber,
"policeNumber": siteCfg.PoliceNumber,
"pageTitle": siteCfg.PageTitle,
"favicon": siteCfg.Favicon,
"umamiScript": siteCfg.UmamiScript,
"umamiWebsiteId": siteCfg.UmamiWebsiteID,
"iconLibrary": siteCfg.IconLibrary,
"fontLibrary": siteCfg.FontLibrary,
"showVisitTimer": visitTimerCfg.ShowVisitTimer,
"footerYearStart": footerCfg.Start,
"footerYearEnd": footerCfg.End,
})
}
}
// GetRotatingTexts 获取轮换文本配置
func GetRotatingTexts(cfg *config.Config) gin.HandlerFunc {
return func(c *gin.Context) {
textsCfg, err := cfg.LoadRotatingTexts()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "加载轮换文本配置失败: " + err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"texts": textsCfg.Texts,
})
}
}
// UpdateRotatingTexts 更新轮换文本配置
func UpdateRotatingTexts(cfg *config.Config) gin.HandlerFunc {
return func(c *gin.Context) {
var req struct {
Texts []string `json:"texts"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// 限制最多8条文本
if len(req.Texts) > 8 {
req.Texts = req.Texts[:8]
}
textsCfg := &config.RotatingTextsConfig{
Texts: req.Texts,
}
if err := cfg.SaveRotatingTexts(textsCfg); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存轮换文本配置失败: " + err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "轮换文本配置已保存",
"texts": textsCfg.Texts,
})
}
}
+180
View File
@@ -0,0 +1,180 @@
package api
import (
"net/http"
"strconv"
"home-vue-go/internal/database"
"home-vue-go/internal/ent/contact"
"github.com/gin-gonic/gin"
)
func GetContacts(db *database.Database) gin.HandlerFunc {
return func(c *gin.Context) {
ctx := c.Request.Context()
contacts, err := db.Client.Contact.Query().Order(contact.BySortOrder(), contact.ByID()).All(ctx)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
result := make([]gin.H, len(contacts))
for i, contact := range contacts {
result[i] = gin.H{
"id": contact.ID,
"type": contact.Type,
"icon": contact.Icon,
"url": contact.URL,
"qrCode": contact.QrCode,
"hoverColor": contact.HoverColor,
"sortOrder": contact.SortOrder,
}
}
c.JSON(http.StatusOK, result)
}
}
func CreateContact(db *database.Database) gin.HandlerFunc {
return func(c *gin.Context) {
var req struct {
Type string `json:"type" binding:"required"`
Icon string `json:"icon" binding:"required"`
URL string `json:"url"`
QrCode string `json:"qrCode"`
HoverColor string `json:"hoverColor"`
SortOrder int `json:"sortOrder"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// 验证Email类型必须是mailto格式
if req.Type == "Email" && req.URL != "" {
if len(req.URL) < 7 || req.URL[:7] != "mailto:" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Email URL必须是mailto:格式"})
return
}
}
ctx := c.Request.Context()
create := db.Client.Contact.Create().
SetType(req.Type).
SetIcon(req.Icon).
SetSortOrder(req.SortOrder)
if req.URL != "" {
create.SetURL(req.URL)
}
if req.QrCode != "" {
create.SetQrCode(req.QrCode)
}
if req.HoverColor != "" {
create.SetHoverColor(req.HoverColor)
}
contact, err := create.Save(ctx)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"id": contact.ID,
"type": contact.Type,
"icon": contact.Icon,
"url": contact.URL,
"qrCode": contact.QrCode,
"hoverColor": contact.HoverColor,
"sortOrder": contact.SortOrder,
})
}
}
func UpdateContact(db *database.Database) gin.HandlerFunc {
return func(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的ID"})
return
}
var req struct {
Type string `json:"type"`
Icon string `json:"icon"`
URL string `json:"url"`
QrCode string `json:"qrCode"`
HoverColor string `json:"hoverColor"`
SortOrder int `json:"sortOrder"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// 验证Email类型必须是mailto格式
if req.Type == "Email" && req.URL != "" {
if len(req.URL) < 7 || req.URL[:7] != "mailto:" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Email URL必须是mailto:格式"})
return
}
}
ctx := c.Request.Context()
update := db.Client.Contact.UpdateOneID(id)
if req.Type != "" {
update.SetType(req.Type)
}
if req.Icon != "" {
update.SetIcon(req.Icon)
}
if req.URL != "" {
update.SetURL(req.URL)
}
if req.QrCode != "" {
update.SetQrCode(req.QrCode)
}
if req.HoverColor != "" {
update.SetHoverColor(req.HoverColor)
}
update.SetSortOrder(req.SortOrder)
contact, err := update.Save(ctx)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"id": contact.ID,
"type": contact.Type,
"icon": contact.Icon,
"url": contact.URL,
"qrCode": contact.QrCode,
"hoverColor": contact.HoverColor,
"sortOrder": contact.SortOrder,
})
}
}
func DeleteContact(db *database.Database) gin.HandlerFunc {
return func(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的ID"})
return
}
ctx := c.Request.Context()
if err := db.Client.Contact.DeleteOneID(id).Exec(ctx); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "删除成功"})
}
}
+58
View File
@@ -0,0 +1,58 @@
package api
import (
"net/http"
"home-vue-go/internal/database"
"github.com/gin-gonic/gin"
)
// GetFrontendConfig 获取前端配置(用于index.html等)
func GetFrontendConfig(db *database.Database) gin.HandlerFunc {
return func(c *gin.Context) {
ctx := c.Request.Context()
config, err := db.Client.SiteConfig.Get(ctx, 1)
if err != nil {
// 如果不存在,返回默认值
c.JSON(http.StatusOK, gin.H{
"title": "个人主页",
"keywords": "个人主页,Vue3",
"description": "一个基于Vue3的个人主页",
"favicon": "/favicon.ico",
"umamiScript": "",
"umamiWebsiteId": "",
"iconLibrary": "//lib.baomitu.com/font-awesome/6.5.0/css/all.min.css",
"fontLibrary": "",
})
return
}
pageTitle := config.PageTitle
if pageTitle == "" {
pageTitle = "个人主页"
}
favicon := config.Favicon
if favicon == "" {
favicon = "/favicon.ico"
}
iconLibrary := config.IconLibrary
if iconLibrary == "" {
iconLibrary = "//lib.baomitu.com/font-awesome/6.5.0/css/all.min.css"
}
// 确保返回完整的配置信息,包括站点名称和URL
c.JSON(http.StatusOK, gin.H{
"title": pageTitle,
"siteName": config.SiteName,
"siteURL": config.SiteURL,
"keywords": config.SiteKeywords,
"description": config.SiteDescription,
"favicon": favicon,
"umamiScript": config.UmamiScript,
"umamiWebsiteId": config.UmamiWebsiteID,
"iconLibrary": iconLibrary,
"fontLibrary": config.FontLibrary,
})
}
}
+70
View File
@@ -0,0 +1,70 @@
package api
import (
"sync"
"time"
)
// 内存存储登录历史(临时方案,等Ent代码生成后改用数据库)
type LoginHistoryRecord struct {
Username string
IP string
Location string
UserAgent string
LoginTime time.Time
Success bool
}
var (
loginHistoryRecords []LoginHistoryRecord
loginHistoryMutex sync.RWMutex
maxLoginHistory = 1000 // 最多保存1000条登录记录
)
// 添加登录历史记录
func addLoginHistory(username, ip, location, userAgent string, success bool) {
loginHistoryMutex.Lock()
defer loginHistoryMutex.Unlock()
loginHistoryRecords = append(loginHistoryRecords, LoginHistoryRecord{
Username: username,
IP: ip,
Location: location,
UserAgent: userAgent,
LoginTime: time.Now(),
Success: success,
})
// 限制记录数量
if len(loginHistoryRecords) > maxLoginHistory {
loginHistoryRecords = loginHistoryRecords[len(loginHistoryRecords)-maxLoginHistory:]
}
}
// 获取登录历史记录
func getLoginHistoryRecords(limit int) []LoginHistoryRecord {
loginHistoryMutex.RLock()
defer loginHistoryMutex.RUnlock()
if limit <= 0 || limit > len(loginHistoryRecords) {
limit = len(loginHistoryRecords)
}
// 返回最后limit条记录(最新的)
start := len(loginHistoryRecords) - limit
if start < 0 {
start = 0
}
result := make([]LoginHistoryRecord, 0, limit)
for i := len(loginHistoryRecords) - 1; i >= start; i-- {
result = append(result, loginHistoryRecords[i])
}
// 反转顺序,使最新的在前
for i, j := 0, len(result)-1; i < j; i, j = i+1, j-1 {
result[i], result[j] = result[j], result[i]
}
return result
}
+167
View File
@@ -0,0 +1,167 @@
package api
import (
"bufio"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"time"
"home-vue-go/internal/config"
"github.com/gin-gonic/gin"
)
var logBuffer []string
var maxLogLines = 1000
// 初始化日志缓冲区
func InitLogBuffer() {
logBuffer = make([]string, 0, maxLogLines)
}
// 添加日志到缓冲区
func AddLogToBuffer(logLine string) {
logBuffer = append(logBuffer, logLine)
if len(logBuffer) > maxLogLines {
logBuffer = logBuffer[len(logBuffer)-maxLogLines:]
}
}
// 获取后端日志
func GetBackendLogs(cfg *config.Config) gin.HandlerFunc {
return func(c *gin.Context) {
lines := c.DefaultQuery("lines", "100")
linesInt := 100
if lines == "all" {
linesInt = maxLogLines
} else {
// 尝试解析lines参数
if n, err := parseIntLogs(lines); err == nil && n > 0 {
linesInt = n
if linesInt > maxLogLines {
linesInt = maxLogLines
}
}
}
// 从缓冲区获取日志
logs := make([]string, 0)
if len(logBuffer) > linesInt {
logs = logBuffer[len(logBuffer)-linesInt:]
} else {
logs = logBuffer
}
// 尝试从日志文件读取(如果存在)
logFile := filepath.Join(cfg.DataDir, "app.log")
if fileLogs, err := readLogFile(logFile, linesInt); err == nil && len(fileLogs) > 0 {
// 合并文件日志和缓冲区日志
allLogs := append(fileLogs, logs...)
// 去重并排序
logs = deduplicateLogs(allLogs)
if len(logs) > linesInt {
logs = logs[len(logs)-linesInt:]
}
}
c.JSON(http.StatusOK, gin.H{
"logs": logs,
"count": len(logs),
})
}
}
// 读取日志文件
func readLogFile(filePath string, maxLines int) ([]string, error) {
file, err := os.Open(filePath)
if err != nil {
return nil, err
}
defer file.Close()
var lines []string
scanner := bufio.NewScanner(file)
for scanner.Scan() {
lines = append(lines, scanner.Text())
if len(lines) >= maxLines*2 {
// 只保留最后maxLines行
lines = lines[len(lines)-maxLines:]
}
}
if err := scanner.Err(); err != nil {
return nil, err
}
// 返回最后maxLines行
if len(lines) > maxLines {
return lines[len(lines)-maxLines:], nil
}
return lines, nil
}
// 去重日志
func deduplicateLogs(logs []string) []string {
seen := make(map[string]bool)
result := make([]string, 0)
for i := len(logs) - 1; i >= 0; i-- {
if !seen[logs[i]] {
seen[logs[i]] = true
result = append([]string{logs[i]}, result...)
}
}
return result
}
// 简单的整数解析(logs.go专用)
func parseIntLogs(s string) (int, error) {
var n int
for _, char := range s {
if char >= '0' && char <= '9' {
n = n*10 + int(char-'0')
} else {
return 0, io.EOF
}
}
return n, nil
}
// 日志中间件 - 记录请求日志
func LoggingMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
start := time.Now()
path := c.Request.URL.Path
query := c.Request.URL.RawQuery
ip := c.ClientIP()
c.Next()
latency := time.Since(start)
method := c.Request.Method
statusCode := c.Writer.Status()
logLine := formatLogLine(start, method, statusCode, latency, path, query, ip)
AddLogToBuffer(logLine)
}
}
func formatLogLine(timestamp time.Time, method string, statusCode int, latency time.Duration, path, query, ip string) string {
if query != "" {
path = path + "?" + query
}
// 使用Asia/Shanghai时区格式化时间
loc, _ := time.LoadLocation("Asia/Shanghai")
shanghaiTime := timestamp.In(loc)
// 格式化日志:只包含一个时间戳
return fmt.Sprintf("[%s] %s %s :: %s %d %s",
shanghaiTime.Format("2006-01-02 15:04:05"),
method,
path,
ip,
statusCode,
latency.String(),
)
}
+81
View File
@@ -0,0 +1,81 @@
package api
import (
"home-vue-go/internal/config"
"home-vue-go/internal/database"
"github.com/gin-gonic/gin"
)
func SetupRoutes(r *gin.Engine, db *database.Database, cfg *config.Config) {
// 初始化日志缓冲区
InitLogBuffer()
// 添加日志中间件
r.Use(LoggingMiddleware())
api := r.Group("/api")
{
// 公开API - 获取数据
api.GET("/sites", GetSites(db))
api.GET("/contacts", GetContacts(db))
api.GET("/config", GetSiteConfig(db, cfg))
api.GET("/frontend-config", GetFrontendConfig(db))
api.GET("/rotating-texts", GetRotatingTexts(cfg))
// 访问统计API(公开,用于记录访问)
api.POST("/track-visit", TrackVisit(db))
// 认证API
auth := api.Group("/auth")
{
auth.POST("/login", Login(db, cfg))
}
// 受保护的管理API
admin := api.Group("/admin")
admin.Use(JWTAuthMiddleware(cfg.JWTSecret))
{
// 站点管理
admin.GET("/sites", GetSites(db))
admin.POST("/sites", CreateSite(db))
admin.PUT("/sites/:id", UpdateSite(db))
admin.DELETE("/sites/:id", DeleteSite(db))
// 联系方式管理
admin.GET("/contacts", GetContacts(db))
admin.POST("/contacts", CreateContact(db))
admin.PUT("/contacts/:id", UpdateContact(db))
admin.DELETE("/contacts/:id", DeleteContact(db))
// 站点配置管理
admin.GET("/config", GetSiteConfig(db, cfg))
admin.PUT("/config", UpdateSiteConfig(db, cfg))
// 轮换文本配置
admin.GET("/rotating-texts", GetRotatingTexts(cfg))
admin.PUT("/rotating-texts", UpdateRotatingTexts(cfg))
// 文件上传
admin.POST("/upload", UploadFile(cfg))
// 统计API
admin.GET("/stats", GetStats(db))
admin.GET("/charts", GetChartData(db))
admin.GET("/recent-visits", GetRecentVisits(db))
admin.POST("/notify-update", NotifyConfigUpdate())
// 用户管理
admin.PUT("/change-password", ChangePassword(db))
// 日志API
admin.GET("/logs", GetBackendLogs(cfg))
// 登录历史API
admin.GET("/login-history", GetLoginHistory(db))
}
}
// 静态文件服务 - 提供上传的图片
r.Static("/uploads", cfg.UploadDir)
}
+139
View File
@@ -0,0 +1,139 @@
package api
import (
"net/http"
"strconv"
"home-vue-go/internal/database"
"home-vue-go/internal/ent/site"
"github.com/gin-gonic/gin"
)
func GetSites(db *database.Database) gin.HandlerFunc {
return func(c *gin.Context) {
ctx := c.Request.Context()
sites, err := db.Client.Site.Query().Order(site.BySortOrder(), site.ByID()).All(ctx)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
result := make([]gin.H, len(sites))
for i, site := range sites {
result[i] = gin.H{
"id": site.ID,
"name": site.Name,
"url": site.URL,
"icon": site.Icon,
"sortOrder": site.SortOrder,
}
}
c.JSON(http.StatusOK, result)
}
}
func CreateSite(db *database.Database) gin.HandlerFunc {
return func(c *gin.Context) {
var req struct {
Name string `json:"name" binding:"required"`
URL string `json:"url" binding:"required"`
Icon string `json:"icon" binding:"required"`
SortOrder int `json:"sortOrder"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
ctx := c.Request.Context()
site, err := db.Client.Site.Create().
SetName(req.Name).
SetURL(req.URL).
SetIcon(req.Icon).
SetSortOrder(req.SortOrder).
Save(ctx)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"id": site.ID,
"name": site.Name,
"url": site.URL,
"icon": site.Icon,
"sortOrder": site.SortOrder,
})
}
}
func UpdateSite(db *database.Database) gin.HandlerFunc {
return func(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的ID"})
return
}
var req struct {
Name string `json:"name"`
URL string `json:"url"`
Icon string `json:"icon"`
SortOrder int `json:"sortOrder"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
ctx := c.Request.Context()
update := db.Client.Site.UpdateOneID(id)
if req.Name != "" {
update.SetName(req.Name)
}
if req.URL != "" {
update.SetURL(req.URL)
}
if req.Icon != "" {
update.SetIcon(req.Icon)
}
update.SetSortOrder(req.SortOrder)
site, err := update.Save(ctx)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"id": site.ID,
"name": site.Name,
"url": site.URL,
"icon": site.Icon,
"sortOrder": site.SortOrder,
})
}
}
func DeleteSite(db *database.Database) gin.HandlerFunc {
return func(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的ID"})
return
}
ctx := c.Request.Context()
if err := db.Client.Site.DeleteOneID(id).Exec(ctx); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "删除成功"})
}
}
+193
View File
@@ -0,0 +1,193 @@
package api
import (
"fmt"
"net/http"
"strings"
"time"
"home-vue-go/internal/database"
"github.com/gin-gonic/gin"
)
// GetStats 获取统计数据
func GetStats(db *database.Database) gin.HandlerFunc {
return func(c *gin.Context) {
ctx := c.Request.Context()
// 获取站点数量
sites, _ := db.Client.Site.Query().All(ctx)
totalSites := len(sites)
// 获取总访问量(从内存记录)
records := getVisitRecords()
totalViews := len(records)
// 获取独立访客数(去重IP
uniqueIPs := make(map[string]bool)
for _, r := range records {
uniqueIPs[r.IP] = true
}
uniqueVisitors := len(uniqueIPs)
// 获取今日访问量
today := time.Now()
todayStart := time.Date(today.Year(), today.Month(), today.Day(), 0, 0, 0, 0, today.Location())
todayViews := 0
for _, r := range records {
if r.VisitTime.After(todayStart) {
todayViews++
}
}
stats := gin.H{
"totalViews": totalViews,
"uniqueVisitors": uniqueVisitors,
"todayViews": todayViews,
"totalSites": totalSites,
}
c.JSON(http.StatusOK, stats)
}
}
// GetChartData 获取图表数据
func GetChartData(db *database.Database) gin.HandlerFunc {
return func(c *gin.Context) {
period := c.DefaultQuery("period", "7")
// 根据period确定天数
periodDays := 7
if period == "30" {
periodDays = 30
} else if period == "90" {
periodDays = 90
}
// 获取真实趋势数据(从内存记录)
records := getVisitRecords()
trend := []gin.H{}
now := time.Now()
for i := 0; i < periodDays; i++ {
date := now.AddDate(0, 0, -(periodDays-1-i))
dayStart := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location())
dayEnd := dayStart.Add(24 * time.Hour)
// 统计当天的访问量
count := 0
for _, r := range records {
if r.VisitTime.After(dayStart) && r.VisitTime.Before(dayEnd) {
count++
}
}
trend = append(trend, gin.H{
"label": date.Format("1/2"),
"value": count,
})
}
// 获取访问来源数据(基于referer)
sourceMap := make(map[string]int)
total := len(records)
for _, r := range records {
ref := strings.ToLower(r.Referer)
if ref == "" {
sourceMap["直接访问"]++
} else if strings.Contains(ref, "google") || strings.Contains(ref, "baidu") || strings.Contains(ref, "bing") || strings.Contains(ref, "yahoo") || strings.Contains(ref, "sogou") {
sourceMap["搜索引擎"]++
} else if strings.Contains(ref, "twitter") || strings.Contains(ref, "facebook") || strings.Contains(ref, "weibo") || strings.Contains(ref, "wechat") || strings.Contains(ref, "qq") {
sourceMap["社交媒体"]++
} else {
sourceMap["其他"]++
}
}
sources := []gin.H{}
if total > 0 {
for label, count := range sourceMap {
sources = append(sources, gin.H{
"label": label,
"value": (count * 100) / total, // 转换为百分比
})
}
} else {
// 如果没有数据,返回默认值
sources = []gin.H{
{"label": "直接访问", "value": 100},
}
}
c.JSON(http.StatusOK, gin.H{
"trend": trend,
"sources": sources,
})
}
}
// GetRecentVisits 获取最近访问记录
func GetRecentVisits(db *database.Database) gin.HandlerFunc {
return func(c *gin.Context) {
// 支持通过 query 参数自定义条数,默认 5,最大 50
limit := 5
if q := c.DefaultQuery("limit", "5"); q != "" {
if n, err := parseInt(q); err == nil && n > 0 {
limit = n
if limit > 50 {
limit = 50
}
}
}
// 从内存记录获取最近访问
records := getVisitRecords()
result := make([]gin.H, 0, limit)
now := time.Now()
// 取最近 limit 条
start := len(records) - limit
if start < 0 {
start = 0
}
for i := len(records) - 1; i >= start && i >= 0; i-- {
r := records[i]
// 计算相对时间
diff := now.Sub(r.VisitTime)
var timeStr string
if diff < time.Minute {
timeStr = "刚刚"
} else if diff < time.Hour {
timeStr = fmt.Sprintf("%.0f分钟前", diff.Minutes())
} else if diff < 24*time.Hour {
timeStr = fmt.Sprintf("%.0f小时前", diff.Hours())
} else {
timeStr = fmt.Sprintf("%.0f天前", diff.Hours()/24)
}
result = append(result, gin.H{
"path": r.Path,
"ip": r.IP,
"time": timeStr,
})
}
c.JSON(http.StatusOK, result)
}
}
// NotifyConfigUpdate 通知配置更新(用于热重载)
func NotifyConfigUpdate() gin.HandlerFunc {
return func(c *gin.Context) {
// 这里可以添加通知逻辑,比如通过WebSocket或SSE通知前端
// 目前简单返回成功
c.JSON(http.StatusOK, gin.H{
"message": "配置更新通知已发送",
})
}
}
+76
View File
@@ -0,0 +1,76 @@
package api
import (
"net/http"
"sync"
"time"
"home-vue-go/internal/database"
"github.com/gin-gonic/gin"
)
// VisitRecord 表示一次访问记录(暂存在内存中)
type VisitRecord struct {
Path string
IP string
UserAgent string
Referer string
VisitTime time.Time
}
// 简单的内存存储与读写锁
var (
visitMutex sync.RWMutex
visitRecords []VisitRecord
maxVisits = 1000 // 最多保留的访问记录条数
)
// TrackVisit 记录访问
func TrackVisit(db *database.Database) gin.HandlerFunc {
return func(c *gin.Context) {
var req struct {
Path string `json:"path"`
Referer string `json:"referer"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
return
}
ip := c.ClientIP()
userAgent := c.GetHeader("User-Agent")
// 记录访问到内存
visitMutex.Lock()
visitRecords = append(visitRecords, VisitRecord{
Path: req.Path,
IP: ip,
UserAgent: userAgent,
Referer: req.Referer,
VisitTime: time.Now(),
})
// 限制记录数量
if len(visitRecords) > maxVisits {
visitRecords = visitRecords[len(visitRecords)-maxVisits:]
}
visitMutex.Unlock()
// TODO: 等Ent代码生成后,改用数据库存储
// ctx := c.Request.Context()
// if db.Client.Visit != nil {
// db.Client.Visit.Create()...
// }
c.JSON(http.StatusOK, gin.H{"message": "访问已记录"})
}
}
// 获取访问记录(从内存)
func getVisitRecords() []VisitRecord {
visitMutex.RLock()
defer visitMutex.RUnlock()
return visitRecords
}
+69
View File
@@ -0,0 +1,69 @@
package api
import (
"fmt"
"net/http"
"path/filepath"
"strings"
"time"
"home-vue-go/internal/config"
"github.com/gin-gonic/gin"
)
var allowedImageExtensions = map[string]bool{
".jpg": true,
".jpeg": true,
".png": true,
".gif": true,
".webp": true,
".avif": true,
".svg": true,
".bmp": true,
".ico": true,
}
func UploadFile(cfg *config.Config) gin.HandlerFunc {
return func(c *gin.Context) {
file, err := c.FormFile("file")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "文件上传失败: " + err.Error()})
return
}
// 检查文件扩展名
ext := strings.ToLower(filepath.Ext(file.Filename))
if !allowedImageExtensions[ext] {
c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("不支持的文件格式。支持的格式: %v", getKeys(allowedImageExtensions)),
})
return
}
// 生成唯一文件名
filename := fmt.Sprintf("%d%s", time.Now().UnixNano(), ext)
dst := filepath.Join(cfg.UploadDir, filename)
// 保存文件
if err := c.SaveUploadedFile(file, dst); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "文件保存失败: " + err.Error()})
return
}
// 返回文件URL(相对于uploads目录)
fileURL := fmt.Sprintf("/uploads/%s", filename)
c.JSON(http.StatusOK, gin.H{
"url": fileURL,
"path": fileURL,
})
}
}
func getKeys(m map[string]bool) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
return keys
}