Add files via upload
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "删除成功"})
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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(),
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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": "删除成功"})
|
||||
}
|
||||
}
|
||||
@@ -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": "配置更新通知已发送",
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user