使用了AI重构项目,并完善了一部分后台问题

This commit is contained in:
2026-08-05 04:47:11 +08:00
parent 146b6b1e6c
commit ac740c24fd
35 changed files with 2876 additions and 980 deletions
+62
View File
@@ -0,0 +1,62 @@
package api
import (
"errors"
"io"
"net/http"
"strings"
"home-vue-go/internal/analytics"
"home-vue-go/internal/config"
"home-vue-go/internal/database"
"github.com/gin-gonic/gin"
)
func TestUmamiConnection(db *database.Database, appConfig *config.Config) gin.HandlerFunc {
return func(c *gin.Context) {
var req struct {
Mode *string `json:"mode"`
APIURL *string `json:"apiUrl"`
WebsiteID *string `json:"websiteId"`
Credential *string `json:"credential"`
}
if err := c.ShouldBindJSON(&req); err != nil && !errors.Is(err, io.EOF) {
c.JSON(http.StatusBadRequest, gin.H{"error": "测试参数格式错误"})
return
}
settings, err := db.LoadSiteSettings(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "加载统计配置失败"})
return
}
credential := ""
if req.Credential != nil {
credential = strings.TrimSpace(*req.Credential)
} else if settings.UmamiCredential != "" && appConfig != nil {
credential, err = appConfig.DecryptSecret(settings.UmamiCredential)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "已保存的统计凭据无法解密"})
return
}
}
mode := settings.UmamiAPIMode
apiURL := settings.UmamiAPIURL
websiteID := settings.UmamiWebsiteID
if req.Mode != nil {
mode = strings.TrimSpace(*req.Mode)
}
if req.APIURL != nil {
apiURL = strings.TrimSpace(*req.APIURL)
}
if req.WebsiteID != nil {
websiteID = strings.TrimSpace(*req.WebsiteID)
}
clientConfig := analytics.Config{Mode: mode, APIURL: apiURL, Credential: credential, WebsiteID: websiteID}
if err := umamiClient.Test(c.Request.Context(), clientConfig); err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "Umami连接失败,请检查地址、Website ID 和凭据"})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true, "message": "Umami连接成功"})
}
}
+263 -237
View File
@@ -1,7 +1,10 @@
package api
import (
"fmt"
"net/http"
"net/url"
"strings"
"home-vue-go/internal/config"
"home-vue-go/internal/database"
@@ -9,271 +12,294 @@ import (
"github.com/gin-gonic/gin"
)
// GetSiteConfig 获取站点配置(含底部年份配置)
func GetSiteConfig(db *database.Database, cfg *config.Config) gin.HandlerFunc {
type updateSiteConfigRequest 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"`
IconLibrary *string `json:"iconLibrary"`
FontLibrary *string `json:"fontLibrary"`
FooterYearStart *string `json:"footerYearStart"`
FooterYearEnd *string `json:"footerYearEnd"`
ShowVisitTimer *bool `json:"showVisitTimer"`
RotatingTexts *[]string `json:"rotatingTexts"`
GreetingText *string `json:"greetingText"`
OnlineStatusText *string `json:"onlineStatusText"`
FooterLabel *string `json:"footerLabel"`
ShowAbout *bool `json:"showAbout"`
ShowSites *bool `json:"showSites"`
ShowContacts *bool `json:"showContacts"`
ShowThemeToggle *bool `json:"showThemeToggle"`
ShowFooter *bool `json:"showFooter"`
AboutTitle *string `json:"aboutTitle"`
AboutDescription *string `json:"aboutDescription"`
AboutLinks *[]config.AboutLink `json:"aboutLinks"`
SitePageSize *int `json:"sitePageSize"`
OpenLinksNewTab *bool `json:"openLinksInNewTab"`
AnalyticsProvider *string `json:"analyticsProvider"`
UmamiScript *string `json:"umamiScript"`
UmamiScriptURL *string `json:"umamiScriptUrl"`
UmamiWebsiteID *string `json:"umamiWebsiteId"`
UmamiAPIMode *string `json:"umamiApiMode"`
UmamiAPIURL *string `json:"umamiApiUrl"`
UmamiCredential *string `json:"umamiCredential"`
ClearUmamiCredential *bool `json:"clearUmamiCredential"`
UmamiDomains *string `json:"umamiDomains"`
UmamiDoNotTrack *bool `json:"umamiDoNotTrack"`
UmamiExcludeSearch *bool `json:"umamiExcludeSearch"`
UmamiExcludeHash *bool `json:"umamiExcludeHash"`
UmamiPerformance *bool `json:"umamiPerformance"`
UmamiTag *string `json:"umamiTag"`
UmamiTrackerOptions *struct {
Domains *string `json:"domains"`
DoNotTrack *bool `json:"doNotTrack"`
ExcludeSearch *bool `json:"excludeSearch"`
ExcludeHash *bool `json:"excludeHash"`
Performance *bool `json:"performance"`
Tag *string `json:"tag"`
} `json:"umamiTrackerOptions"`
}
func GetSiteConfig(db *database.Database) 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)
settings, err := db.LoadSiteSettings(c.Request.Context())
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,
})
c.JSON(http.StatusInternalServerError, gin.H{"error": "加载站点配置失败"})
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,
})
c.JSON(http.StatusOK, siteConfigResponse(settings, false))
}
}
func GetAdminSiteConfig(db *database.Database) gin.HandlerFunc {
return func(c *gin.Context) {
settings, err := db.LoadSiteSettings(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "加载站点配置失败"})
return
}
c.JSON(http.StatusOK, siteConfigResponse(settings, true))
}
}
// 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"`
var req updateSiteConfigRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "配置参数格式错误"})
return
}
if err := c.ShouldBindJSON(&req); err != nil {
ctx := c.Request.Context()
settings, err := db.LoadSiteSettings(ctx)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "加载现有站点配置失败"})
return
}
next := settings.Clone()
applyString(&next.SiteName, req.SiteName)
applyString(&next.SiteURL, req.SiteURL)
applyString(&next.SiteIcon, req.SiteIcon)
applyString(&next.SiteDescription, req.SiteDescription)
applyString(&next.SiteKeywords, req.SiteKeywords)
applyString(&next.UserName, req.UserName)
applyString(&next.ProfileImageURL, req.ProfileImageURL)
applyString(&next.ICPNumber, req.ICPNumber)
applyString(&next.PoliceNumber, req.PoliceNumber)
applyString(&next.PageTitle, req.PageTitle)
applyString(&next.Favicon, req.Favicon)
applyString(&next.IconLibrary, req.IconLibrary)
applyString(&next.FontLibrary, req.FontLibrary)
applyString(&next.FooterYearStart, req.FooterYearStart)
applyString(&next.FooterYearEnd, req.FooterYearEnd)
applyBool(&next.ShowVisitTimer, req.ShowVisitTimer)
applySlice(&next.RotatingTexts, req.RotatingTexts)
applyString(&next.GreetingText, req.GreetingText)
applyString(&next.OnlineStatusText, req.OnlineStatusText)
applyString(&next.FooterLabel, req.FooterLabel)
applyBool(&next.ShowAbout, req.ShowAbout)
applyBool(&next.ShowSites, req.ShowSites)
applyBool(&next.ShowContacts, req.ShowContacts)
applyBool(&next.ShowThemeToggle, req.ShowThemeToggle)
applyBool(&next.ShowFooter, req.ShowFooter)
applyString(&next.AboutTitle, req.AboutTitle)
applyString(&next.AboutDescription, req.AboutDescription)
applySlice(&next.AboutLinks, req.AboutLinks)
applyInt(&next.SitePageSize, req.SitePageSize)
applyBool(&next.OpenLinksNewTab, req.OpenLinksNewTab)
applyString(&next.AnalyticsProvider, req.AnalyticsProvider)
applyString(&next.UmamiScript, req.UmamiScript)
applyString(&next.UmamiScript, req.UmamiScriptURL)
applyString(&next.UmamiWebsiteID, req.UmamiWebsiteID)
applyString(&next.UmamiAPIMode, req.UmamiAPIMode)
applyString(&next.UmamiAPIURL, req.UmamiAPIURL)
applyString(&next.UmamiDomains, req.UmamiDomains)
applyBool(&next.UmamiDoNotTrack, req.UmamiDoNotTrack)
applyBool(&next.UmamiExcludeSearch, req.UmamiExcludeSearch)
applyBool(&next.UmamiExcludeHash, req.UmamiExcludeHash)
applyBool(&next.UmamiPerformance, req.UmamiPerformance)
applyString(&next.UmamiTag, req.UmamiTag)
if options := req.UmamiTrackerOptions; options != nil {
applyString(&next.UmamiDomains, options.Domains)
applyBool(&next.UmamiDoNotTrack, options.DoNotTrack)
applyBool(&next.UmamiExcludeSearch, options.ExcludeSearch)
applyBool(&next.UmamiExcludeHash, options.ExcludeHash)
applyBool(&next.UmamiPerformance, options.Performance)
applyString(&next.UmamiTag, options.Tag)
}
if req.ClearUmamiCredential != nil && *req.ClearUmamiCredential {
next.UmamiCredential = ""
} else if req.UmamiCredential != nil {
if strings.TrimSpace(*req.UmamiCredential) == "" {
next.UmamiCredential = ""
} else {
if cfg == nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "加密配置不可用"})
return
}
encrypted, err := cfg.EncryptSecret(strings.TrimSpace(*req.UmamiCredential))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存统计凭据失败"})
return
}
next.UmamiCredential = encrypted
}
}
if err := validateSiteSettings(next); 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()})
if err := db.SaveSiteSettings(ctx, next); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点配置失败"})
return
}
c.JSON(http.StatusOK, gin.H{
"texts": textsCfg.Texts,
})
c.JSON(http.StatusOK, siteConfigResponse(next, true))
}
}
// UpdateRotatingTexts 更新轮换文本配置
func UpdateRotatingTexts(cfg *config.Config) gin.HandlerFunc {
func GetRotatingTexts(db *database.Database) gin.HandlerFunc {
return func(c *gin.Context) {
settings, err := db.LoadSiteSettings(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "加载轮换文本配置失败"})
return
}
c.JSON(http.StatusOK, gin.H{"texts": settings.RotatingTexts})
}
}
func UpdateRotatingTexts(db *database.Database) 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()})
c.JSON(http.StatusBadRequest, gin.H{"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()})
settings, err := db.LoadSiteSettings(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "加载站点配置失败"})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "轮换文本配置已保存",
"texts": textsCfg.Texts,
})
settings.RotatingTexts = req.Texts
settings.Normalize()
if err := db.SaveSiteSettings(c.Request.Context(), settings); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存轮换文本配置失败"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "轮换文本配置已保存", "texts": settings.RotatingTexts})
}
}
func siteConfigResponse(settings *config.SiteSettings, admin bool) gin.H {
result := gin.H{
"siteName": settings.SiteName, "siteURL": settings.SiteURL, "siteIcon": settings.SiteIcon,
"siteDescription": settings.SiteDescription, "siteKeywords": settings.SiteKeywords, "userName": settings.UserName,
"profileImageURL": settings.ProfileImageURL, "icpNumber": settings.ICPNumber, "policeNumber": settings.PoliceNumber,
"pageTitle": settings.PageTitle, "favicon": settings.Favicon, "iconLibrary": settings.IconLibrary, "fontLibrary": settings.FontLibrary,
"footerYearStart": settings.FooterYearStart, "footerYearEnd": settings.FooterYearEnd, "showVisitTimer": settings.ShowVisitTimer,
"rotatingTexts": settings.RotatingTexts, "greetingText": settings.GreetingText, "onlineStatusText": settings.OnlineStatusText,
"footerLabel": settings.FooterLabel, "showAbout": settings.ShowAbout, "showSites": settings.ShowSites, "showContacts": settings.ShowContacts,
"showThemeToggle": settings.ShowThemeToggle, "showFooter": settings.ShowFooter, "aboutTitle": settings.AboutTitle,
"aboutDescription": settings.AboutDescription, "aboutLinks": settings.AboutLinks, "sitePageSize": settings.SitePageSize,
"openLinksInNewTab": settings.OpenLinksNewTab, "analyticsProvider": settings.AnalyticsProvider, "umamiScript": settings.UmamiScript, "umamiScriptUrl": settings.UmamiScript,
"umamiWebsiteId": settings.UmamiWebsiteID, "umamiDomains": settings.UmamiDomains, "umamiDoNotTrack": settings.UmamiDoNotTrack,
"umamiExcludeSearch": settings.UmamiExcludeSearch, "umamiExcludeHash": settings.UmamiExcludeHash,
"umamiPerformance": settings.UmamiPerformance, "umamiTag": settings.UmamiTag,
"umamiTrackerOptions": gin.H{"domains": settings.UmamiDomains, "doNotTrack": settings.UmamiDoNotTrack, "excludeSearch": settings.UmamiExcludeSearch, "excludeHash": settings.UmamiExcludeHash, "performance": settings.UmamiPerformance, "tag": settings.UmamiTag},
}
if admin {
result["umamiApiMode"] = settings.UmamiAPIMode
result["umamiApiUrl"] = settings.UmamiAPIURL
result["umamiCredentialConfigured"] = settings.UmamiCredential != ""
}
return result
}
func validateSiteSettings(settings *config.SiteSettings) error {
for name, value := range map[string]string{"站点URL": settings.SiteURL, "Umami脚本地址": settings.UmamiScript, "Umami API地址": settings.UmamiAPIURL} {
if value == "" {
continue
}
parsed, err := url.ParseRequestURI(value)
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" {
return fmt.Errorf("%s必须是有效的 http/https 地址", name)
}
}
if settings.SitePageSize != 6 && settings.SitePageSize != 9 && settings.SitePageSize != 12 {
return fmt.Errorf("站点每页数量只能是 6、9 或 12")
}
if settings.AnalyticsProvider != "local" && settings.AnalyticsProvider != "umami" {
return fmt.Errorf("统计来源只能是 local 或 umami")
}
if settings.UmamiAPIMode != "selfhost" && settings.UmamiAPIMode != "cloud" {
return fmt.Errorf("Umami API 模式不正确")
}
if len(settings.AboutLinks) > 8 {
return fmt.Errorf("关于链接最多支持 8 条")
}
for _, link := range settings.AboutLinks {
if strings.TrimSpace(link.URL) == "" {
return fmt.Errorf("关于链接地址不能为空")
}
parsed, err := url.ParseRequestURI(link.URL)
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" {
return fmt.Errorf("关于链接必须是有效的 http/https 地址")
}
}
return nil
}
func applyString(target *string, value *string) {
if value != nil {
*target = strings.TrimSpace(*value)
}
}
func applyBool(target *bool, value *bool) {
if value != nil {
*target = *value
}
}
func applyInt(target *int, value *int) {
if value != nil {
*target = *value
}
}
func applySlice[T any](target *[]T, value *[]T) {
if value != nil {
*target = *value
}
}
+177 -38
View File
@@ -3,8 +3,10 @@ package api
import (
"net/http"
"strconv"
"strings"
"home-vue-go/internal/database"
"home-vue-go/internal/ent"
"home-vue-go/internal/ent/contact"
"github.com/gin-gonic/gin"
@@ -22,13 +24,104 @@ func GetContacts(db *database.Database) gin.HandlerFunc {
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,
"id": contact.ID,
"type": contact.Type,
"icon": contact.Icon,
"url": contact.URL,
"qrCode": contact.QrCode,
"hoverColor": contact.HoverColor,
"sortOrder": contact.SortOrder,
"sortOrder": contact.SortOrder,
}
}
c.JSON(http.StatusOK, result)
}
}
func ReorderContacts(db *database.Database) gin.HandlerFunc {
return func(c *gin.Context) {
var req struct {
IDs []int `json:"ids" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if len(req.IDs) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "排序列表不能为空"})
return
}
seen := make(map[int]bool, len(req.IDs))
for _, id := range req.IDs {
if id <= 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "排序列表包含无效ID"})
return
}
if seen[id] {
c.JSON(http.StatusBadRequest, gin.H{"error": "排序列表包含重复ID"})
return
}
seen[id] = true
}
ctx := c.Request.Context()
existingIDs, err := db.Client.Contact.Query().IDs(ctx)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if len(existingIDs) != len(req.IDs) {
c.JSON(http.StatusBadRequest, gin.H{"error": "排序列表与当前联系方式数量不一致"})
return
}
existing := make(map[int]bool, len(existingIDs))
for _, id := range existingIDs {
existing[id] = true
}
for _, id := range req.IDs {
if !existing[id] {
c.JSON(http.StatusBadRequest, gin.H{"error": "排序列表包含不存在的联系方式"})
return
}
}
tx, err := db.Client.Tx(ctx)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
for index, id := range req.IDs {
if _, err := tx.Contact.UpdateOneID(id).SetSortOrder((index + 1) * 10).Save(ctx); err != nil {
_ = tx.Rollback()
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
}
if err := tx.Commit(); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
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,
}
}
@@ -51,13 +144,31 @@ func CreateContact(db *database.Database) gin.HandlerFunc {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
req.Type = strings.TrimSpace(req.Type)
req.Icon = strings.TrimSpace(req.Icon)
req.URL = strings.TrimSpace(req.URL)
req.QrCode = strings.TrimSpace(req.QrCode)
req.HoverColor = strings.TrimSpace(req.HoverColor)
if req.Type == "" || req.Icon == "" {
c.JSON(http.StatusBadRequest, gin.H{"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
}
if req.URL != "" && !validContactURL(req.URL) {
c.JSON(http.StatusBadRequest, gin.H{"error": "联系方式URL必须是有效的http/https/mailto/tel地址"})
return
}
if req.QrCode != "" && !validQRCode(req.QrCode) {
c.JSON(http.StatusBadRequest, gin.H{"error": "二维码必须使用本地上传路径或安全的http/https图片地址"})
return
}
if req.URL != "" && req.QrCode != "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "联系方式不能同时设置链接和二维码"})
return
}
if req.URL == "" && req.QrCode == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "链接或二维码不能为空"})
return
}
ctx := c.Request.Context()
@@ -83,13 +194,13 @@ func CreateContact(db *database.Database) gin.HandlerFunc {
}
c.JSON(http.StatusOK, gin.H{
"id": contact.ID,
"type": contact.Type,
"icon": contact.Icon,
"url": contact.URL,
"qrCode": contact.QrCode,
"id": contact.ID,
"type": contact.Type,
"icon": contact.Icon,
"url": contact.URL,
"qrCode": contact.QrCode,
"hoverColor": contact.HoverColor,
"sortOrder": contact.SortOrder,
"sortOrder": contact.SortOrder,
})
}
}
@@ -103,8 +214,8 @@ func UpdateContact(db *database.Database) gin.HandlerFunc {
}
var req struct {
Type string `json:"type"`
Icon string `json:"icon"`
Type string `json:"type" binding:"required"`
Icon string `json:"icon" binding:"required"`
URL string `json:"url"`
QrCode string `json:"qrCode"`
HoverColor string `json:"hoverColor"`
@@ -115,48 +226,72 @@ func UpdateContact(db *database.Database) gin.HandlerFunc {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
req.Type = strings.TrimSpace(req.Type)
req.Icon = strings.TrimSpace(req.Icon)
req.URL = strings.TrimSpace(req.URL)
req.QrCode = strings.TrimSpace(req.QrCode)
req.HoverColor = strings.TrimSpace(req.HoverColor)
if req.Type == "" || req.Icon == "" {
c.JSON(http.StatusBadRequest, gin.H{"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
}
if req.URL != "" && !validContactURL(req.URL) {
c.JSON(http.StatusBadRequest, gin.H{"error": "联系方式URL必须是有效的http/https/mailto/tel地址"})
return
}
if req.QrCode != "" && !validQRCode(req.QrCode) {
c.JSON(http.StatusBadRequest, gin.H{"error": "二维码必须使用本地上传路径或安全的http/https图片地址"})
return
}
if req.URL != "" && req.QrCode != "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "联系方式不能同时设置链接和二维码"})
return
}
if req.URL == "" && req.QrCode == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "链接或二维码不能为空"})
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)
}
update.SetType(req.Type)
update.SetIcon(req.Icon)
if req.URL != "" {
update.SetURL(req.URL)
} else {
update.ClearURL()
}
if req.QrCode != "" {
update.SetQrCode(req.QrCode)
} else {
update.ClearQrCode()
}
if req.HoverColor != "" {
update.SetHoverColor(req.HoverColor)
} else {
update.ClearHoverColor()
}
update.SetSortOrder(req.SortOrder)
contact, err := update.Save(ctx)
if err != nil {
if ent.IsNotFound(err) {
c.JSON(http.StatusNotFound, gin.H{"error": "联系方式不存在"})
return
}
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,
"id": contact.ID,
"type": contact.Type,
"icon": contact.Icon,
"url": contact.URL,
"qrCode": contact.QrCode,
"hoverColor": contact.HoverColor,
"sortOrder": contact.SortOrder,
"sortOrder": contact.SortOrder,
})
}
}
@@ -171,6 +306,10 @@ func DeleteContact(db *database.Database) gin.HandlerFunc {
ctx := c.Request.Context()
if err := db.Client.Contact.DeleteOneID(id).Exec(ctx); err != nil {
if ent.IsNotFound(err) {
c.JSON(http.StatusNotFound, gin.H{"error": "联系方式不存在"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
+22 -39
View File
@@ -8,51 +8,34 @@ import (
"github.com/gin-gonic/gin"
)
// GetFrontendConfig 获取前端配置(用于index.html等)
// GetFrontendConfig returns only public head, runtime and tracker settings.
func GetFrontendConfig(db *database.Database) gin.HandlerFunc {
return func(c *gin.Context) {
ctx := c.Request.Context()
config, err := db.Client.SiteConfig.Get(ctx, 1)
settings, err := db.LoadSiteSettings(c.Request.Context())
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": "",
})
c.JSON(http.StatusInternalServerError, gin.H{"error": "加载前端配置失败"})
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,
"title": settings.PageTitle,
"siteName": settings.SiteName,
"siteURL": settings.SiteURL,
"keywords": settings.SiteKeywords,
"description": settings.SiteDescription,
"favicon": settings.Favicon,
"iconLibrary": settings.IconLibrary,
"fontLibrary": settings.FontLibrary,
"analyticsProvider": settings.AnalyticsProvider,
"umamiScript": settings.UmamiScript,
"umamiScriptUrl": settings.UmamiScript,
"umamiWebsiteId": settings.UmamiWebsiteID,
"umamiDomains": settings.UmamiDomains,
"umamiDoNotTrack": settings.UmamiDoNotTrack,
"umamiExcludeSearch": settings.UmamiExcludeSearch,
"umamiExcludeHash": settings.UmamiExcludeHash,
"umamiPerformance": settings.UmamiPerformance,
"umamiTag": settings.UmamiTag,
"umamiTrackerOptions": gin.H{"domains": settings.UmamiDomains, "doNotTrack": settings.UmamiDoNotTrack, "excludeSearch": settings.UmamiExcludeSearch, "excludeHash": settings.UmamiExcludeHash, "performance": settings.UmamiPerformance, "tag": settings.UmamiTag},
})
}
}
+11 -5
View File
@@ -7,6 +7,7 @@ import (
"net/http"
"os"
"path/filepath"
"sync"
"time"
"home-vue-go/internal/config"
@@ -16,14 +17,19 @@ import (
var logBuffer []string
var maxLogLines = 1000
var logBufferMutex sync.RWMutex
// 初始化日志缓冲区
func InitLogBuffer() {
logBufferMutex.Lock()
defer logBufferMutex.Unlock()
logBuffer = make([]string, 0, maxLogLines)
}
// 添加日志到缓冲区
func AddLogToBuffer(logLine string) {
logBufferMutex.Lock()
defer logBufferMutex.Unlock()
logBuffer = append(logBuffer, logLine)
if len(logBuffer) > maxLogLines {
logBuffer = logBuffer[len(logBuffer)-maxLogLines:]
@@ -48,11 +54,11 @@ func GetBackendLogs(cfg *config.Config) gin.HandlerFunc {
}
// 从缓冲区获取日志
logs := make([]string, 0)
if len(logBuffer) > linesInt {
logs = logBuffer[len(logBuffer)-linesInt:]
} else {
logs = logBuffer
logBufferMutex.RLock()
logs := append([]string(nil), logBuffer...)
logBufferMutex.RUnlock()
if len(logs) > linesInt {
logs = logs[len(logs)-linesInt:]
}
// 尝试从日志文件读取(如果存在)
+16 -13
View File
@@ -10,19 +10,19 @@ import (
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("/config", GetSiteConfig(db))
api.GET("/frontend-config", GetFrontendConfig(db))
api.GET("/rotating-texts", GetRotatingTexts(cfg))
api.GET("/rotating-texts", GetRotatingTexts(db))
// 访问统计API(公开,用于记录访问)
api.POST("/track-visit", TrackVisit(db))
@@ -39,38 +39,41 @@ func SetupRoutes(r *gin.Engine, db *database.Database, cfg *config.Config) {
// 站点管理
admin.GET("/sites", GetSites(db))
admin.POST("/sites", CreateSite(db))
admin.PUT("/sites/reorder", ReorderSites(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/reorder", ReorderContacts(db))
admin.PUT("/contacts/:id", UpdateContact(db))
admin.DELETE("/contacts/:id", DeleteContact(db))
// 站点配置管理
admin.GET("/config", GetSiteConfig(db, cfg))
admin.GET("/config", GetAdminSiteConfig(db))
admin.PUT("/config", UpdateSiteConfig(db, cfg))
// 轮换文本配置
admin.GET("/rotating-texts", GetRotatingTexts(cfg))
admin.PUT("/rotating-texts", UpdateRotatingTexts(cfg))
admin.GET("/rotating-texts", GetRotatingTexts(db))
admin.PUT("/rotating-texts", UpdateRotatingTexts(db))
// 文件上传
admin.POST("/upload", UploadFile(cfg))
// 统计API
admin.GET("/stats", GetStats(db))
admin.GET("/charts", GetChartData(db))
admin.GET("/stats", GetStats(db, cfg))
admin.GET("/charts", GetChartData(db, cfg))
admin.GET("/recent-visits", GetRecentVisits(db))
admin.POST("/analytics/test", TestUmamiConnection(db, cfg))
admin.POST("/notify-update", NotifyConfigUpdate())
// 用户管理
admin.PUT("/change-password", ChangePassword(db))
// 日志API
admin.GET("/logs", GetBackendLogs(cfg))
// 登录历史API
admin.GET("/login-history", GetLoginHistory(db))
}
+112 -9
View File
@@ -3,8 +3,10 @@ package api
import (
"net/http"
"strconv"
"strings"
"home-vue-go/internal/database"
"home-vue-go/internal/ent"
"home-vue-go/internal/ent/site"
"github.com/gin-gonic/gin"
@@ -34,6 +36,95 @@ func GetSites(db *database.Database) gin.HandlerFunc {
}
}
func ReorderSites(db *database.Database) gin.HandlerFunc {
return func(c *gin.Context) {
var req struct {
IDs []int `json:"ids" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if len(req.IDs) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "排序列表不能为空"})
return
}
seen := make(map[int]bool, len(req.IDs))
for _, id := range req.IDs {
if id <= 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "排序列表包含无效ID"})
return
}
if seen[id] {
c.JSON(http.StatusBadRequest, gin.H{"error": "排序列表包含重复ID"})
return
}
seen[id] = true
}
ctx := c.Request.Context()
existingIDs, err := db.Client.Site.Query().IDs(ctx)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if len(existingIDs) != len(req.IDs) {
c.JSON(http.StatusBadRequest, gin.H{"error": "排序列表与当前站点数量不一致"})
return
}
existing := make(map[int]bool, len(existingIDs))
for _, id := range existingIDs {
existing[id] = true
}
for _, id := range req.IDs {
if !existing[id] {
c.JSON(http.StatusBadRequest, gin.H{"error": "排序列表包含不存在的站点"})
return
}
}
tx, err := db.Client.Tx(ctx)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
for index, id := range req.IDs {
if _, err := tx.Site.UpdateOneID(id).SetSortOrder((index + 1) * 10).Save(ctx); err != nil {
_ = tx.Rollback()
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
}
if err := tx.Commit(); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
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 {
@@ -47,6 +138,11 @@ func CreateSite(db *database.Database) gin.HandlerFunc {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
req.Name, req.URL, req.Icon = strings.TrimSpace(req.Name), strings.TrimSpace(req.URL), strings.TrimSpace(req.Icon)
if req.Name == "" || req.URL == "" || req.Icon == "" || !validHTTPURL(req.URL) {
c.JSON(http.StatusBadRequest, gin.H{"error": "站点名称、图标不能为空,URL必须是有效的http/https地址"})
return
}
ctx := c.Request.Context()
site, err := db.Client.Site.Create().
@@ -90,22 +186,25 @@ func UpdateSite(db *database.Database) gin.HandlerFunc {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
req.Name, req.URL, req.Icon = strings.TrimSpace(req.Name), strings.TrimSpace(req.URL), strings.TrimSpace(req.Icon)
if req.Name == "" || req.URL == "" || req.Icon == "" || !validHTTPURL(req.URL) {
c.JSON(http.StatusBadRequest, gin.H{"error": "站点名称、图标不能为空,URL必须是有效的http/https地址"})
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.SetName(req.Name)
update.SetURL(req.URL)
update.SetIcon(req.Icon)
update.SetSortOrder(req.SortOrder)
site, err := update.Save(ctx)
if err != nil {
if ent.IsNotFound(err) {
c.JSON(http.StatusNotFound, gin.H{"error": "站点不存在"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
@@ -130,6 +229,10 @@ func DeleteSite(db *database.Database) gin.HandlerFunc {
ctx := c.Request.Context()
if err := db.Client.Site.DeleteOneID(id).Exec(ctx); err != nil {
if ent.IsNotFound(err) {
c.JSON(http.StatusNotFound, gin.H{"error": "站点不存在"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
+344 -154
View File
@@ -3,191 +3,381 @@ package api
import (
"fmt"
"net/http"
"sort"
"strconv"
"strings"
"sync"
"time"
"home-vue-go/internal/analytics"
"home-vue-go/internal/config"
"home-vue-go/internal/database"
"home-vue-go/internal/ent/visit"
"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,
}
type analyticsCache struct {
mu sync.Mutex
statsExpires time.Time
chartsExpires time.Time
statsKey string
chartsKey string
stats gin.H
trend []gin.H
sources []gin.H
}
c.JSON(http.StatusOK, stats)
var dashboardAnalyticsCache analyticsCache
var umamiClient = analytics.NewClient()
func GetStats(db *database.Database, cfg *config.Config) gin.HandlerFunc {
return func(c *gin.Context) {
settings, err := db.LoadSiteSettings(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "加载统计配置失败"})
return
}
siteCount, _ := db.Client.Site.Query().Count(c.Request.Context())
if settings.AnalyticsProvider == "umami" {
stats, status, err := loadUmamiStats(c, settings, siteCount, cfg)
if err != nil {
c.JSON(status, gin.H{"error": "Umami统计暂时不可用", "analyticsSource": "umami", "analyticsConfigured": false, "analyticsError": "unavailable"})
return
}
c.JSON(http.StatusOK, stats)
return
}
c.JSON(http.StatusOK, localStats(c, db, siteCount))
}
}
// GetChartData 获取图表数据
func GetChartData(db *database.Database) gin.HandlerFunc {
func GetChartData(db *database.Database, cfg *config.Config) 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
settings, err := db.LoadSiteSettings(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "加载统计配置失败"})
return
}
// 获取真实趋势数据(从内存记录)
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++
}
periodDays := chartPeriodDays(c.DefaultQuery("period", "7"))
if settings.AnalyticsProvider == "umami" {
trend, sources, status, err := loadUmamiCharts(c, settings, periodDays, cfg)
if err != nil {
c.JSON(status, gin.H{"error": "Umami统计暂时不可用", "analyticsSource": "umami", "analyticsConfigured": false, "analyticsError": "unavailable"})
return
}
trend = append(trend, gin.H{
"label": date.Format("1/2"),
"value": count,
})
c.JSON(http.StatusOK, gin.H{"trend": trend, "sources": sources, "analyticsSource": "umami", "analyticsConfigured": true, "analyticsError": ""})
return
}
// 获取访问来源数据(基于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,
})
trend, sources := localCharts(c, db, periodDays)
c.JSON(http.StatusOK, gin.H{"trend": trend, "sources": sources, "analyticsSource": "local", "analyticsConfigured": true, "analyticsError": ""})
}
}
// GetRecentVisits 获取最近访问记录
func GetRecentVisits(db *database.Database) gin.HandlerFunc {
return func(c *gin.Context) {
// 支持通过 query 参数自定义条数,默认 5,最大 50
settings, err := db.LoadSiteSettings(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "加载统计配置失败"})
return
}
if settings.AnalyticsProvider == "umami" {
c.JSON(http.StatusOK, []gin.H{})
return
}
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
}
if value, err := strconv.Atoi(c.DefaultQuery("limit", "5")); err == nil && value > 0 {
limit = value
if limit > 50 {
limit = 50
}
}
// 从内存记录获取最近访问
records := getVisitRecords()
result := make([]gin.H, 0, limit)
records, err := queryVisits(c, db, 0)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "加载访问记录失败"})
return
}
result := make([]gin.H, 0, min(limit, len(records)))
now := time.Now()
// 取最近 limit 条
start := len(records) - limit
if start < 0 {
start = 0
for index := len(records) - 1; index >= 0 && len(result) < limit; index-- {
record := records[index]
result = append(result, gin.H{"path": record.Path, "ip": record.IP, "time": relativeVisitTime(now, record.VisitTime)})
}
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": "配置更新通知已发送",
})
resetAnalyticsCache()
c.JSON(http.StatusOK, gin.H{"message": "配置更新通知已发送"})
}
}
func loadUmamiStats(c *gin.Context, settings *config.SiteSettings, siteCount int, appConfig *config.Config) (gin.H, int, error) {
credential, err := decryptUmamiCredential(settings, appConfig)
if err != nil {
return nil, http.StatusBadGateway, err
}
cacheKey := "stats:" + settings.UmamiWebsiteID + ":" + settings.UmamiAPIURL
dashboardAnalyticsCache.mu.Lock()
if dashboardAnalyticsCache.statsKey == cacheKey && time.Now().Before(dashboardAnalyticsCache.statsExpires) && dashboardAnalyticsCache.stats != nil {
value := dashboardAnalyticsCache.stats
dashboardAnalyticsCache.mu.Unlock()
return value, http.StatusOK, nil
}
dashboardAnalyticsCache.mu.Unlock()
clientConfig := analytics.Config{Mode: settings.UmamiAPIMode, APIURL: settings.UmamiAPIURL, Credential: credential, WebsiteID: settings.UmamiWebsiteID}
now := time.Now()
allTime, err := umamiClient.GetStats(c.Request.Context(), clientConfig, time.Unix(0, 0), now)
if err != nil {
return nil, http.StatusBadGateway, err
}
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
todayStats, err := umamiClient.GetStats(c.Request.Context(), clientConfig, today, now)
if err != nil {
return nil, http.StatusBadGateway, err
}
active, err := umamiClient.GetActive(c.Request.Context(), clientConfig)
if err != nil {
return nil, http.StatusBadGateway, err
}
result := gin.H{"totalViews": allTime.Pageviews, "uniqueVisitors": allTime.Visitors, "todayViews": todayStats.Pageviews, "activeVisitors": active, "totalSites": siteCount, "analyticsSource": "umami", "analyticsConfigured": true}
dashboardAnalyticsCache.mu.Lock()
dashboardAnalyticsCache.statsKey, dashboardAnalyticsCache.statsExpires, dashboardAnalyticsCache.stats = cacheKey, time.Now().Add(30*time.Second), result
dashboardAnalyticsCache.mu.Unlock()
return result, http.StatusOK, nil
}
func loadUmamiCharts(c *gin.Context, settings *config.SiteSettings, periodDays int, appConfig *config.Config) ([]gin.H, []gin.H, int, error) {
credential, err := decryptUmamiCredential(settings, appConfig)
if err != nil {
return nil, nil, http.StatusBadGateway, err
}
cacheKey := fmt.Sprintf("charts:%s:%d:%s", settings.UmamiWebsiteID, periodDays, settings.UmamiAPIURL)
dashboardAnalyticsCache.mu.Lock()
if dashboardAnalyticsCache.chartsKey == cacheKey && time.Now().Before(dashboardAnalyticsCache.chartsExpires) && dashboardAnalyticsCache.trend != nil {
trend, sources := dashboardAnalyticsCache.trend, dashboardAnalyticsCache.sources
dashboardAnalyticsCache.mu.Unlock()
return trend, sources, http.StatusOK, nil
}
dashboardAnalyticsCache.mu.Unlock()
clientConfig := analytics.Config{Mode: settings.UmamiAPIMode, APIURL: settings.UmamiAPIURL, Credential: credential, WebsiteID: settings.UmamiWebsiteID}
now := time.Now()
start := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()).AddDate(0, 0, -periodDays+1)
pageviews, err := umamiClient.GetPageviews(c.Request.Context(), clientConfig, start, now)
if err != nil {
return nil, nil, http.StatusBadGateway, err
}
referrers, err := umamiClient.GetMetrics(c.Request.Context(), clientConfig, start, now, "referrer")
if err != nil {
return nil, nil, http.StatusBadGateway, err
}
trend := make([]gin.H, 0, len(pageviews))
for _, point := range pageviews {
trend = append(trend, gin.H{"label": point.X, "value": point.Y})
}
sources := percentageSources(referrers)
dashboardAnalyticsCache.mu.Lock()
dashboardAnalyticsCache.chartsKey, dashboardAnalyticsCache.chartsExpires, dashboardAnalyticsCache.trend, dashboardAnalyticsCache.sources = cacheKey, time.Now().Add(30*time.Second), trend, sources
dashboardAnalyticsCache.mu.Unlock()
return trend, sources, http.StatusOK, nil
}
func localStats(c *gin.Context, db *database.Database, siteCount int) gin.H {
records, err := queryVisits(c, db, 0)
if err != nil {
return gin.H{"totalViews": 0, "uniqueVisitors": 0, "todayViews": 0, "activeVisitors": 0, "totalSites": siteCount, "analyticsSource": "local", "analyticsConfigured": true, "analyticsError": "query_failed"}
}
now := time.Now()
start := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
unique := map[string]struct{}{}
active := map[string]struct{}{}
todayViews := 0
for _, record := range records {
key := record.IP + "|" + record.UserAgent
unique[key] = struct{}{}
if !record.VisitTime.Before(start) {
todayViews++
}
if !record.VisitTime.Before(now.Add(-5 * time.Minute)) {
active[key] = struct{}{}
}
}
return gin.H{"totalViews": len(records), "uniqueVisitors": len(unique), "todayViews": todayViews, "activeVisitors": len(active), "totalSites": siteCount, "analyticsSource": "local", "analyticsConfigured": true, "analyticsError": ""}
}
func localCharts(c *gin.Context, db *database.Database, periodDays int) ([]gin.H, []gin.H) {
records, err := queryVisits(c, db, 0)
if err != nil {
return emptyTrend(periodDays), []gin.H{{"label": "直接访问", "value": 100}}
}
now := time.Now()
trend := make([]gin.H, 0, periodDays)
for index := 0; index < periodDays; index++ {
date := now.AddDate(0, 0, -(periodDays - 1 - index))
dayStart := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location())
dayEnd := dayStart.AddDate(0, 0, 1)
count := 0
for _, record := range records {
if !record.VisitTime.Before(dayStart) && record.VisitTime.Before(dayEnd) {
count++
}
}
trend = append(trend, gin.H{"label": date.Format("1/2"), "value": count})
}
counts := make(map[string]int)
for _, record := range records {
counts[classifyReferer(record.Referer)]++
}
sources := percentageCounts(counts, len(records))
return trend, sources
}
func queryVisits(c *gin.Context, db *database.Database, days int) ([]*structVisit, error) {
query := db.Client.Visit.Query().Order(visit.ByVisitTime())
if days > 0 {
query = query.Where(visit.VisitTimeGTE(time.Now().AddDate(0, 0, -days)))
}
rows, err := query.All(c.Request.Context())
if err != nil {
return nil, err
}
result := make([]*structVisit, len(rows))
for index, row := range rows {
result[index] = &structVisit{Path: row.Path, IP: row.IP, UserAgent: row.UserAgent, Referer: row.Referer, VisitTime: row.VisitTime}
}
return result, nil
}
type structVisit struct {
Path, IP, UserAgent, Referer string
VisitTime time.Time
}
func percentageSources(points []analytics.Point) []gin.H {
counts := make(map[string]int)
for _, point := range points {
counts[classifyReferer(point.X)] += point.Y
}
total := 0
for _, count := range counts {
total += count
}
return percentageCounts(counts, total)
}
func percentageCounts(counts map[string]int, total int) []gin.H {
if total <= 0 {
return []gin.H{{"label": "直接访问", "value": 100}}
}
type item struct {
label string
count, value, remainder int
}
items := make([]item, 0, len(counts))
used := 0
for label, count := range counts {
value := count * 100 / total
items = append(items, item{label: label, count: count, value: value, remainder: count * 100 % total})
used += value
}
sort.Slice(items, func(i, j int) bool {
if items[i].remainder != items[j].remainder {
return items[i].remainder > items[j].remainder
}
return items[i].label < items[j].label
})
for index := 0; index < 100-used && len(items) > 0; index++ {
items[index%len(items)].value++
}
sort.Slice(items, func(i, j int) bool {
if items[i].count != items[j].count {
return items[i].count > items[j].count
}
return items[i].label < items[j].label
})
result := make([]gin.H, 0, len(items))
for _, item := range items {
result = append(result, gin.H{"label": item.label, "value": item.value})
}
return result
}
func classifyReferer(referrer string) string {
referrer = strings.ToLower(strings.TrimSpace(referrer))
if referrer == "" {
return "直接访问"
}
for _, source := range []string{"google", "baidu", "bing", "yahoo", "sogou"} {
if strings.Contains(referrer, source) {
return "搜索引擎"
}
}
for _, source := range []string{"twitter", "facebook", "weibo", "wechat", "qq"} {
if strings.Contains(referrer, source) {
return "社交媒体"
}
}
return "其他"
}
func decryptUmamiCredential(settings *config.SiteSettings, appConfig *config.Config) (string, error) {
if settings.UmamiWebsiteID == "" || settings.UmamiCredential == "" {
return "", fmt.Errorf("Umami配置不完整")
}
if appConfig == nil {
return "", fmt.Errorf("统计加密配置不可用")
}
return appConfig.DecryptSecret(settings.UmamiCredential)
}
func resetAnalyticsCache() {
dashboardAnalyticsCache.mu.Lock()
dashboardAnalyticsCache.statsKey = ""
dashboardAnalyticsCache.chartsKey = ""
dashboardAnalyticsCache.statsExpires = time.Time{}
dashboardAnalyticsCache.chartsExpires = time.Time{}
dashboardAnalyticsCache.stats = nil
dashboardAnalyticsCache.trend = nil
dashboardAnalyticsCache.sources = nil
dashboardAnalyticsCache.mu.Unlock()
}
func chartPeriodDays(value string) int {
switch value {
case "30":
return 30
case "90":
return 90
default:
return 7
}
}
func emptyTrend(days int) []gin.H {
result := make([]gin.H, days)
for index := range result {
result[index] = gin.H{"label": "", "value": 0}
}
return result
}
func relativeVisitTime(now, visited time.Time) string {
diff := now.Sub(visited)
switch {
case diff < time.Minute:
return "刚刚"
case diff < time.Hour:
return fmt.Sprintf("%.0f分钟前", diff.Minutes())
case diff < 24*time.Hour:
return fmt.Sprintf("%.0f小时前", diff.Hours())
default:
return fmt.Sprintf("%.0f天前", diff.Hours()/24)
}
}
func min(left, right int) int {
if left < right {
return left
}
return right
}
+27 -53
View File
@@ -2,75 +2,49 @@ package api
import (
"net/http"
"sync"
"time"
"strings"
"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 记录访问
// TrackVisit stores a local visit only when local analytics is selected.
// Umami mode is tracked by the browser and must not be duplicated here.
func TrackVisit(db *database.Database) gin.HandlerFunc {
return func(c *gin.Context) {
settings, err := db.LoadSiteSettings(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "加载统计配置失败"})
return
}
if settings.AnalyticsProvider == "umami" {
c.JSON(http.StatusNoContent, nil)
return
}
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:]
path := strings.TrimSpace(req.Path)
if path == "" || len(path) > 2048 {
c.JSON(http.StatusBadRequest, gin.H{"error": "访问路径无效"})
return
}
visitMutex.Unlock()
// TODO: 等Ent代码生成后,改用数据库存储
// ctx := c.Request.Context()
// if db.Client.Visit != nil {
// db.Client.Visit.Create()...
// }
c.JSON(http.StatusOK, gin.H{"message": "访问已记录"})
_, err = db.Client.Visit.Create().
SetPath(path).
SetIP(c.ClientIP()).
SetUserAgent(c.GetHeader("User-Agent")).
SetReferer(strings.TrimSpace(req.Referer)).
Save(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "访问记录保存失败"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "访问已记录", "analyticsSource": "local"})
}
}
// 获取访问记录(从内存)
func getVisitRecords() []VisitRecord {
visitMutex.RLock()
defer visitMutex.RUnlock()
return visitRecords
}
+32
View File
@@ -0,0 +1,32 @@
package api
import (
"net/url"
"path"
"strings"
)
func validHTTPURL(value string) bool {
parsed, err := url.ParseRequestURI(strings.TrimSpace(value))
return err == nil && parsed.Host != "" && (parsed.Scheme == "http" || parsed.Scheme == "https")
}
func validContactURL(value string) bool {
value = strings.TrimSpace(value)
parsed, err := url.ParseRequestURI(value)
if err != nil {
return false
}
if parsed.Scheme == "http" || parsed.Scheme == "https" {
return parsed.Host != ""
}
return parsed.Scheme == "mailto" || parsed.Scheme == "tel"
}
func validQRCode(value string) bool {
value = strings.TrimSpace(value)
if strings.HasPrefix(value, "/uploads/") {
return !strings.Contains(value, "\\") && path.Clean(value) == value
}
return validHTTPURL(value)
}
+42
View File
@@ -0,0 +1,42 @@
package api
import "testing"
func TestURLValidation(t *testing.T) {
for _, value := range []string{"https://example.com", "http://localhost:8080"} {
if !validHTTPURL(value) {
t.Errorf("expected valid site URL: %s", value)
}
}
for _, value := range []string{"javascript:alert(1)", "//example.com", "example.com"} {
if validHTTPURL(value) {
t.Errorf("expected invalid site URL: %s", value)
}
}
for _, value := range []string{"mailto:a@example.com", "tel:+8613800000000", "https://example.com"} {
if !validContactURL(value) {
t.Errorf("expected valid contact URL: %s", value)
}
}
for _, value := range []string{"/uploads/qr.png", "https://example.com/qr.png"} {
if !validQRCode(value) {
t.Errorf("expected valid QR path: %s", value)
}
}
for _, value := range []string{"/uploads/../secret.png", "data:image/png;base64,abc"} {
if validQRCode(value) {
t.Errorf("expected invalid QR path: %s", value)
}
}
}
func TestPercentageCountsSumsToOneHundred(t *testing.T) {
values := percentageCounts(map[string]int{"direct": 1, "search": 1, "other": 1}, 3)
total := 0
for _, value := range values {
total += value["value"].(int)
}
if total != 100 {
t.Fatalf("percentage total = %d, want 100", total)
}
}