使用了AI重构项目,并完善了一部分后台问题
This commit is contained in:
@@ -0,0 +1,188 @@
|
||||
package analytics
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Mode string
|
||||
APIURL string
|
||||
Credential string
|
||||
WebsiteID string
|
||||
}
|
||||
|
||||
type Stats struct {
|
||||
Pageviews int
|
||||
Visitors int
|
||||
Visits int
|
||||
Bounces int
|
||||
TotalTime int64
|
||||
}
|
||||
|
||||
type Point struct {
|
||||
X string `json:"x"`
|
||||
Y int `json:"y"`
|
||||
}
|
||||
|
||||
type Client struct {
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
func NewClient() *Client {
|
||||
return &Client{httpClient: &http.Client{Timeout: 5 * time.Second}}
|
||||
}
|
||||
|
||||
func (c *Client) GetStats(ctx context.Context, cfg Config, startAt, endAt time.Time) (Stats, error) {
|
||||
var response struct {
|
||||
Pageviews json.RawMessage `json:"pageviews"`
|
||||
Visitors json.RawMessage `json:"visitors"`
|
||||
Visits json.RawMessage `json:"visits"`
|
||||
Bounces json.RawMessage `json:"bounces"`
|
||||
TotalTime json.RawMessage `json:"totaltime"`
|
||||
}
|
||||
if err := c.get(ctx, cfg, "/websites/"+url.PathEscape(cfg.WebsiteID)+"/stats", startAt, endAt, "", &response); err != nil {
|
||||
return Stats{}, err
|
||||
}
|
||||
return Stats{
|
||||
Pageviews: rawNumber(response.Pageviews),
|
||||
Visitors: rawNumber(response.Visitors),
|
||||
Visits: rawNumber(response.Visits),
|
||||
Bounces: rawNumber(response.Bounces),
|
||||
TotalTime: int64(rawNumber(response.TotalTime)),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *Client) GetActive(ctx context.Context, cfg Config) (int, error) {
|
||||
var response struct {
|
||||
Visitors int `json:"visitors"`
|
||||
}
|
||||
if err := c.get(ctx, cfg, "/websites/"+url.PathEscape(cfg.WebsiteID)+"/active", time.Time{}, time.Time{}, "", &response); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return response.Visitors, nil
|
||||
}
|
||||
|
||||
func (c *Client) GetPageviews(ctx context.Context, cfg Config, startAt, endAt time.Time) ([]Point, error) {
|
||||
var response struct {
|
||||
Pageviews []Point `json:"pageviews"`
|
||||
}
|
||||
if err := c.get(ctx, cfg, "/websites/"+url.PathEscape(cfg.WebsiteID)+"/pageviews", startAt, endAt, "day", &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return response.Pageviews, nil
|
||||
}
|
||||
|
||||
func (c *Client) GetMetrics(ctx context.Context, cfg Config, startAt, endAt time.Time, metricType string) ([]Point, error) {
|
||||
var response []Point
|
||||
if err := c.get(ctx, cfg, "/websites/"+url.PathEscape(cfg.WebsiteID)+"/metrics", startAt, endAt, metricType, &response); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (c *Client) Test(ctx context.Context, cfg Config) error {
|
||||
if strings.TrimSpace(cfg.WebsiteID) == "" {
|
||||
return errors.New("Umami Website ID 不能为空")
|
||||
}
|
||||
if strings.TrimSpace(cfg.Credential) == "" {
|
||||
return errors.New("Umami API 凭据未配置")
|
||||
}
|
||||
_, err := c.GetStats(ctx, cfg, time.Now().Add(-24*time.Hour), time.Now())
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *Client) get(ctx context.Context, cfg Config, path string, startAt, endAt time.Time, unitOrType string, target any) error {
|
||||
baseURL, err := normalizeBaseURL(cfg.Mode, cfg.APIURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
requestURL, err := url.Parse(baseURL + path)
|
||||
if err != nil {
|
||||
return errors.New("Umami API 地址无效")
|
||||
}
|
||||
query := requestURL.Query()
|
||||
if !startAt.IsZero() {
|
||||
query.Set("startAt", strconv.FormatInt(startAt.UnixMilli(), 10))
|
||||
}
|
||||
if !endAt.IsZero() {
|
||||
query.Set("endAt", strconv.FormatInt(endAt.UnixMilli(), 10))
|
||||
}
|
||||
if strings.HasSuffix(path, "/pageviews") && unitOrType != "" {
|
||||
query.Set("unit", unitOrType)
|
||||
}
|
||||
if strings.HasSuffix(path, "/metrics") && unitOrType != "" {
|
||||
query.Set("type", unitOrType)
|
||||
}
|
||||
requestURL.RawQuery = query.Encode()
|
||||
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, requestURL.String(), nil)
|
||||
if err != nil {
|
||||
return errors.New("创建 Umami 请求失败")
|
||||
}
|
||||
request.Header.Set("Accept", "application/json")
|
||||
if cfg.Mode == "cloud" {
|
||||
request.Header.Set("x-umami-api-key", cfg.Credential)
|
||||
} else {
|
||||
request.Header.Set("Authorization", "Bearer "+cfg.Credential)
|
||||
}
|
||||
response, err := c.httpClient.Do(request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Umami 请求失败: %w", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||
return fmt.Errorf("Umami 返回 HTTP %d", response.StatusCode)
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(response.Body, 2<<20))
|
||||
if err != nil {
|
||||
return errors.New("读取 Umami 响应失败")
|
||||
}
|
||||
if err := json.Unmarshal(body, target); err != nil {
|
||||
return errors.New("解析 Umami 响应失败")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeBaseURL(mode, raw string) (string, error) {
|
||||
base := strings.TrimRight(strings.TrimSpace(raw), "/")
|
||||
if base == "" && mode == "cloud" {
|
||||
base = "https://api.umami.is/v1"
|
||||
}
|
||||
if base == "" {
|
||||
return "", errors.New("Umami API 地址不能为空")
|
||||
}
|
||||
parsed, err := url.Parse(base)
|
||||
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" {
|
||||
return "", errors.New("Umami API 地址必须是有效的 http/https 地址")
|
||||
}
|
||||
if mode == "cloud" && !strings.HasSuffix(parsed.Path, "/v1") {
|
||||
base += "/v1"
|
||||
}
|
||||
return strings.TrimRight(base, "/"), nil
|
||||
}
|
||||
|
||||
func rawNumber(raw json.RawMessage) int {
|
||||
if len(raw) == 0 || string(raw) == "null" {
|
||||
return 0
|
||||
}
|
||||
var number float64
|
||||
if json.Unmarshal(raw, &number) == nil {
|
||||
return int(number)
|
||||
}
|
||||
var wrapped struct {
|
||||
Value float64 `json:"value"`
|
||||
}
|
||||
if json.Unmarshal(raw, &wrapped) == nil {
|
||||
return int(wrapped.Value)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package analytics
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSelfHostedStatsAndPageviews(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") != "Bearer self-secret" {
|
||||
t.Errorf("missing self-hosted auth header: %q", r.Header.Get("Authorization"))
|
||||
}
|
||||
if strings.HasSuffix(r.URL.Path, "/pageviews") && r.URL.Query().Get("unit") != "day" {
|
||||
t.Errorf("missing pageviews unit: %q", r.URL.RawQuery)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if strings.HasSuffix(r.URL.Path, "/pageviews") {
|
||||
_, _ = w.Write([]byte(`{"pageviews":[{"x":"8/5","y":4}]}`))
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"pageviews":{"value":12},"visitors":3,"visits":5,"bounces":1,"totaltime":90}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient()
|
||||
cfg := Config{Mode: "selfhost", APIURL: server.URL, Credential: "self-secret", WebsiteID: "website"}
|
||||
stats, err := client.GetStats(context.Background(), cfg, time.Unix(0, 0), time.Now())
|
||||
if err != nil || stats.Pageviews != 12 || stats.Visitors != 3 {
|
||||
t.Fatalf("unexpected stats: %+v, %v", stats, err)
|
||||
}
|
||||
points, err := client.GetPageviews(context.Background(), cfg, time.Now().Add(-24*time.Hour), time.Now())
|
||||
if err != nil || len(points) != 1 || points[0].Y != 4 {
|
||||
t.Fatalf("unexpected pageviews: %+v, %v", points, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloudUsesAPIKey(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("x-umami-api-key") != "cloud-secret" || r.Header.Get("Authorization") != "" {
|
||||
t.Errorf("unexpected cloud auth headers: api-key=%q authorization=%q", r.Header.Get("x-umami-api-key"), r.Header.Get("Authorization"))
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"visitors":1}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient()
|
||||
_, err := client.GetStats(context.Background(), Config{Mode: "cloud", APIURL: server.URL + "/v1", Credential: "cloud-secret", WebsiteID: "website"}, time.Time{}, time.Time{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,25 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
DataDir string
|
||||
DatabasePath string
|
||||
UploadDir string
|
||||
JWTSecret string
|
||||
DataDir string
|
||||
DatabasePath string
|
||||
UploadDir string
|
||||
JWTSecret string
|
||||
EncryptionKey []byte
|
||||
}
|
||||
|
||||
func New(dataDir string) *Config {
|
||||
@@ -26,10 +36,71 @@ func New(dataDir string) *Config {
|
||||
jwtSecret = "your-secret-key-change-in-production"
|
||||
}
|
||||
|
||||
encryptionSource := os.Getenv("CONFIG_ENCRYPTION_KEY")
|
||||
if encryptionSource == "" {
|
||||
// Keep existing installations decryptable while allowing production deployments
|
||||
// to use a dedicated key that is independent from JWT signing.
|
||||
encryptionSource = jwtSecret
|
||||
}
|
||||
encryptionKey := sha256.Sum256([]byte(encryptionSource))
|
||||
|
||||
return &Config{
|
||||
DataDir: dataDir,
|
||||
DatabasePath: filepath.Join(dataDir, "home.db"),
|
||||
UploadDir: uploadDir,
|
||||
JWTSecret: jwtSecret,
|
||||
DataDir: dataDir,
|
||||
DatabasePath: filepath.Join(dataDir, "home.db"),
|
||||
UploadDir: uploadDir,
|
||||
JWTSecret: jwtSecret,
|
||||
EncryptionKey: encryptionKey[:],
|
||||
}
|
||||
}
|
||||
|
||||
// EncryptSecret encrypts a value for storage in the application database.
|
||||
func (c *Config) EncryptSecret(value string) (string, error) {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return "", nil
|
||||
}
|
||||
block, err := aes.NewCipher(c.EncryptionKey)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
nonce := make([]byte, gcm.NonceSize())
|
||||
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||
return "", err
|
||||
}
|
||||
ciphertext := gcm.Seal(nil, nonce, []byte(value), nil)
|
||||
encoded := base64.RawStdEncoding.EncodeToString(append(nonce, ciphertext...))
|
||||
return "v1:" + encoded, nil
|
||||
}
|
||||
|
||||
// DecryptSecret decrypts a value previously returned by EncryptSecret.
|
||||
func (c *Config) DecryptSecret(value string) (string, error) {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return "", nil
|
||||
}
|
||||
if !strings.HasPrefix(value, "v1:") {
|
||||
return "", errors.New("unsupported encrypted secret format")
|
||||
}
|
||||
raw, err := base64.RawStdEncoding.DecodeString(strings.TrimPrefix(value, "v1:"))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("decode encrypted secret: %w", err)
|
||||
}
|
||||
block, err := aes.NewCipher(c.EncryptionKey)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(raw) < gcm.NonceSize() {
|
||||
return "", errors.New("encrypted secret is too short")
|
||||
}
|
||||
plaintext, err := gcm.Open(nil, raw[:gcm.NonceSize()], raw[gcm.NonceSize():], nil)
|
||||
if err != nil {
|
||||
return "", errors.New("encrypted secret authentication failed")
|
||||
}
|
||||
return string(plaintext), nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package config
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestSecretRoundTripAndIsolation(t *testing.T) {
|
||||
first := &Config{EncryptionKey: []byte("01234567890123456789012345678901")}
|
||||
second := &Config{EncryptionKey: []byte("abcdefghijklmnopqrstuvwxyz123456")}
|
||||
ciphertext, err := first.EncryptSecret("umami-secret")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ciphertext == "umami-secret" || ciphertext == "" {
|
||||
t.Fatalf("secret was not encrypted: %q", ciphertext)
|
||||
}
|
||||
plaintext, err := first.DecryptSecret(ciphertext)
|
||||
if err != nil || plaintext != "umami-secret" {
|
||||
t.Fatalf("round trip failed: %q, %v", plaintext, err)
|
||||
}
|
||||
if _, err := second.DecryptSecret(ciphertext); err == nil {
|
||||
t.Fatal("ciphertext decrypted with the wrong key")
|
||||
}
|
||||
cleared, err := first.EncryptSecret("")
|
||||
if err != nil || cleared != "" {
|
||||
t.Fatalf("empty secret should clear storage: %q, %v", cleared, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package config
|
||||
|
||||
import "strings"
|
||||
|
||||
// AboutLink is a configurable link displayed in the about dialog.
|
||||
type AboutLink struct {
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
URL string `json:"url"`
|
||||
Icon string `json:"icon"`
|
||||
}
|
||||
|
||||
// SiteSettings is the single JSON payload stored alongside the SiteConfig row.
|
||||
// The encrypted Umami credential is intentionally kept in this internal type.
|
||||
type SiteSettings 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 []AboutLink `json:"aboutLinks"`
|
||||
SitePageSize int `json:"sitePageSize"`
|
||||
OpenLinksNewTab bool `json:"openLinksInNewTab"`
|
||||
|
||||
AnalyticsProvider string `json:"analyticsProvider"`
|
||||
UmamiScript string `json:"umamiScript"`
|
||||
UmamiWebsiteID string `json:"umamiWebsiteId"`
|
||||
UmamiAPIMode string `json:"umamiApiMode"`
|
||||
UmamiAPIURL string `json:"umamiApiUrl"`
|
||||
UmamiCredential string `json:"umamiCredential"`
|
||||
UmamiDomains string `json:"umamiDomains"`
|
||||
UmamiDoNotTrack bool `json:"umamiDoNotTrack"`
|
||||
UmamiExcludeSearch bool `json:"umamiExcludeSearch"`
|
||||
UmamiExcludeHash bool `json:"umamiExcludeHash"`
|
||||
UmamiPerformance bool `json:"umamiPerformance"`
|
||||
UmamiTag string `json:"umamiTag"`
|
||||
}
|
||||
|
||||
var defaultRotatingTexts = []string{
|
||||
"你好鸭,欢迎来到我的主页!!",
|
||||
"随时可以联系我,期待与你交流。",
|
||||
"愿你历尽千帆,归来仍是少年。",
|
||||
"梦想还是要有的,万一实现了呢?",
|
||||
"I hope you have a happy day every day.",
|
||||
}
|
||||
|
||||
func DefaultSiteSettings() *SiteSettings {
|
||||
return &SiteSettings{
|
||||
SiteName: "个人主页",
|
||||
SiteURL: "https://example.com",
|
||||
SiteIcon: "/favicon.ico",
|
||||
SiteDescription: "一个基于Vue3的个人主页",
|
||||
SiteKeywords: "个人主页,Vue3",
|
||||
UserName: "用户",
|
||||
PageTitle: "个人主页",
|
||||
Favicon: "/favicon.ico",
|
||||
FooterYearStart: "",
|
||||
FooterYearEnd: "",
|
||||
ShowVisitTimer: true,
|
||||
RotatingTexts: append([]string(nil), defaultRotatingTexts...),
|
||||
GreetingText: "Hi,",
|
||||
OnlineStatusText: "在线中",
|
||||
FooterLabel: "Made by",
|
||||
ShowAbout: true,
|
||||
ShowSites: true,
|
||||
ShowContacts: true,
|
||||
ShowThemeToggle: true,
|
||||
ShowFooter: true,
|
||||
AboutTitle: "关于本站",
|
||||
AboutLinks: []AboutLink{
|
||||
{Title: "静态原项目", Description: "Home-Vue", URL: "https://github.com/JLinMr/Home-Vue", Icon: "fab fa-github"},
|
||||
{Title: "动态现项目", Description: "Home-Vue-go", URL: "https://github.com/QWQLwToo/Home-Vue-go", Icon: "fab fa-github"},
|
||||
},
|
||||
SitePageSize: 6,
|
||||
OpenLinksNewTab: true,
|
||||
AnalyticsProvider: "local",
|
||||
UmamiAPIMode: "selfhost",
|
||||
UmamiDoNotTrack: true,
|
||||
UmamiExcludeSearch: false,
|
||||
UmamiExcludeHash: false,
|
||||
UmamiPerformance: false,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SiteSettings) Normalize() {
|
||||
defaults := DefaultSiteSettings()
|
||||
if strings.TrimSpace(s.SiteName) == "" {
|
||||
s.SiteName = defaults.SiteName
|
||||
}
|
||||
if strings.TrimSpace(s.SiteURL) == "" {
|
||||
s.SiteURL = defaults.SiteURL
|
||||
}
|
||||
if strings.TrimSpace(s.SiteIcon) == "" {
|
||||
s.SiteIcon = defaults.SiteIcon
|
||||
}
|
||||
if strings.TrimSpace(s.SiteDescription) == "" {
|
||||
s.SiteDescription = defaults.SiteDescription
|
||||
}
|
||||
if strings.TrimSpace(s.SiteKeywords) == "" {
|
||||
s.SiteKeywords = defaults.SiteKeywords
|
||||
}
|
||||
if strings.TrimSpace(s.UserName) == "" {
|
||||
s.UserName = defaults.UserName
|
||||
}
|
||||
if strings.TrimSpace(s.PageTitle) == "" {
|
||||
s.PageTitle = defaults.PageTitle
|
||||
}
|
||||
if strings.TrimSpace(s.Favicon) == "" {
|
||||
s.Favicon = defaults.Favicon
|
||||
}
|
||||
if strings.TrimSpace(s.GreetingText) == "" {
|
||||
s.GreetingText = defaults.GreetingText
|
||||
}
|
||||
if strings.TrimSpace(s.OnlineStatusText) == "" {
|
||||
s.OnlineStatusText = defaults.OnlineStatusText
|
||||
}
|
||||
if strings.TrimSpace(s.FooterLabel) == "" {
|
||||
s.FooterLabel = defaults.FooterLabel
|
||||
}
|
||||
if strings.TrimSpace(s.AboutTitle) == "" {
|
||||
s.AboutTitle = defaults.AboutTitle
|
||||
}
|
||||
if s.SitePageSize != 6 && s.SitePageSize != 9 && s.SitePageSize != 12 {
|
||||
s.SitePageSize = defaults.SitePageSize
|
||||
}
|
||||
if s.AnalyticsProvider != "umami" {
|
||||
s.AnalyticsProvider = "local"
|
||||
}
|
||||
if s.UmamiAPIMode != "cloud" {
|
||||
s.UmamiAPIMode = "selfhost"
|
||||
}
|
||||
|
||||
filteredTexts := make([]string, 0, 8)
|
||||
for _, text := range s.RotatingTexts {
|
||||
if value := strings.TrimSpace(text); value != "" {
|
||||
filteredTexts = append(filteredTexts, value)
|
||||
}
|
||||
if len(filteredTexts) == 8 {
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(filteredTexts) == 0 {
|
||||
filteredTexts = append([]string(nil), defaults.RotatingTexts...)
|
||||
}
|
||||
s.RotatingTexts = filteredTexts
|
||||
if len(s.AboutLinks) > 8 {
|
||||
s.AboutLinks = s.AboutLinks[:8]
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SiteSettings) Clone() *SiteSettings {
|
||||
copyValue := *s
|
||||
copyValue.RotatingTexts = append([]string(nil), s.RotatingTexts...)
|
||||
copyValue.AboutLinks = append([]AboutLink(nil), s.AboutLinks...)
|
||||
return ©Value
|
||||
}
|
||||
+215
-41
@@ -3,10 +3,16 @@ package database
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"log"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"entgo.io/ent/dialect"
|
||||
entsql "entgo.io/ent/dialect/sql"
|
||||
"home-vue-go/internal/config"
|
||||
"home-vue-go/internal/ent"
|
||||
"home-vue-go/internal/ent/migrate"
|
||||
|
||||
@@ -14,11 +20,14 @@ import (
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
const siteConfigPayloadColumn = "config_json"
|
||||
|
||||
type Database struct {
|
||||
Client *ent.Client
|
||||
SQL *sql.DB
|
||||
}
|
||||
|
||||
func Init(dbPath string) (*Database, error) {
|
||||
func Init(dbPath string, cfg *config.Config) (*Database, error) {
|
||||
db, err := sql.Open("sqlite3", dbPath+"?_fk=1")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -26,74 +35,239 @@ func Init(dbPath string) (*Database, error) {
|
||||
|
||||
drv := entsql.OpenDB(dialect.SQLite, db)
|
||||
client := ent.NewClient(ent.Driver(drv))
|
||||
|
||||
// 运行数据库迁移
|
||||
ctx := context.Background()
|
||||
if err := client.Schema.Create(ctx, migrate.WithForeignKeys(false)); err != nil {
|
||||
log.Fatalf("数据库迁移失败: %v", err)
|
||||
_ = db.Close()
|
||||
return nil, fmt.Errorf("数据库迁移失败: %w", err)
|
||||
}
|
||||
if err := ensureSiteConfigPayload(db); err != nil {
|
||||
_ = client.Close()
|
||||
_ = db.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 初始化默认数据
|
||||
database := &Database{Client: client, SQL: db}
|
||||
if err := initDefaultData(ctx, client); err != nil {
|
||||
log.Printf("初始化默认数据失败: %v", err)
|
||||
_ = database.Close()
|
||||
return nil, fmt.Errorf("初始化默认数据失败: %w", err)
|
||||
}
|
||||
|
||||
return &Database{Client: client}, nil
|
||||
if err := initializeSiteSettings(ctx, database, cfg); err != nil {
|
||||
_ = database.Close()
|
||||
return nil, fmt.Errorf("初始化站点配置载荷失败: %w", err)
|
||||
}
|
||||
return database, nil
|
||||
}
|
||||
|
||||
func (d *Database) Close() error {
|
||||
return d.Client.Close()
|
||||
if d.Client != nil {
|
||||
return d.Client.Close()
|
||||
}
|
||||
if d.SQL != nil {
|
||||
return d.SQL.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func initDefaultData(ctx context.Context, client *ent.Client) error {
|
||||
// 检查是否已有站点配置
|
||||
_, err := client.SiteConfig.Get(ctx, 1)
|
||||
if err == nil {
|
||||
// 已存在配置,不初始化
|
||||
return nil
|
||||
}
|
||||
|
||||
// 创建默认站点配置
|
||||
_, err = client.SiteConfig.Create().
|
||||
SetSiteName("个人主页").
|
||||
SetSiteURL("https://example.com").
|
||||
SetSiteIcon("/favicon.ico").
|
||||
SetSiteDescription("一个基于Vue3的个人主页").
|
||||
SetSiteKeywords("个人主页,Vue3").
|
||||
SetUserName("用户").
|
||||
SetProfileImageURL("").
|
||||
SetIcpNumber("").
|
||||
SetPoliceNumber("").
|
||||
SetPageTitle("个人主页").
|
||||
SetFavicon("/favicon.ico").
|
||||
SetUmamiScript("").
|
||||
SetUmamiWebsiteID("").
|
||||
SetIconLibrary("//lib.baomitu.com/font-awesome/6.5.0/css/all.min.css").
|
||||
SetFontLibrary("").
|
||||
Save(ctx)
|
||||
func ensureSiteConfigPayload(db *sql.DB) error {
|
||||
rows, err := db.Query("PRAGMA table_info(site_configs)")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var cid int
|
||||
var name, columnType string
|
||||
var notNull, primaryKey int
|
||||
var defaultValue any
|
||||
if err := rows.Scan(&cid, &name, &columnType, ¬Null, &defaultValue, &primaryKey); err != nil {
|
||||
return err
|
||||
}
|
||||
if name == siteConfigPayloadColumn {
|
||||
return rows.Err()
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = db.Exec(`ALTER TABLE site_configs ADD COLUMN config_json TEXT NOT NULL DEFAULT '{}'`)
|
||||
return err
|
||||
}
|
||||
|
||||
func initDefaultData(ctx context.Context, client *ent.Client) error {
|
||||
if _, err := client.SiteConfig.Get(ctx, 1); err != nil {
|
||||
if !ent.IsNotFound(err) {
|
||||
return err
|
||||
}
|
||||
if _, err := client.SiteConfig.Create().
|
||||
SetSiteName("个人主页").
|
||||
SetSiteURL("https://example.com").
|
||||
SetSiteIcon("/favicon.ico").
|
||||
SetSiteDescription("一个基于Vue3的个人主页").
|
||||
SetSiteKeywords("个人主页,Vue3").
|
||||
SetUserName("用户").
|
||||
SetProfileImageURL("").
|
||||
SetIcpNumber("").
|
||||
SetPoliceNumber("").
|
||||
SetPageTitle("个人主页").
|
||||
SetFavicon("/favicon.ico").
|
||||
SetUmamiScript("").
|
||||
SetUmamiWebsiteID("").
|
||||
SetIconLibrary("").
|
||||
SetFontLibrary("").
|
||||
Save(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否已有用户,如果没有才创建默认管理员用户
|
||||
userCount, err := client.User.Query().Count(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 只有在没有任何用户时才创建默认管理员
|
||||
if userCount == 0 {
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte("admin123"), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = client.User.Create().
|
||||
SetUsername("admin").
|
||||
SetPassword(string(hashedPassword)).
|
||||
Save(ctx)
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func initializeSiteSettings(ctx context.Context, db *Database, cfg *config.Config) error {
|
||||
payload, err := db.loadSiteConfigPayload(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(payload) != "" && strings.TrimSpace(payload) != "{}" {
|
||||
var settings config.SiteSettings
|
||||
if err := json.Unmarshal([]byte(payload), &settings); err != nil {
|
||||
return fmt.Errorf("解析站点配置载荷失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
siteCfg, err := db.Client.SiteConfig.Get(ctx, 1)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
settings := config.DefaultSiteSettings()
|
||||
settings.SiteName = siteCfg.SiteName
|
||||
settings.SiteURL = siteCfg.SiteURL
|
||||
settings.SiteIcon = siteCfg.SiteIcon
|
||||
settings.SiteDescription = siteCfg.SiteDescription
|
||||
settings.SiteKeywords = siteCfg.SiteKeywords
|
||||
settings.UserName = siteCfg.UserName
|
||||
settings.ProfileImageURL = siteCfg.ProfileImageURL
|
||||
settings.ICPNumber = siteCfg.IcpNumber
|
||||
settings.PoliceNumber = siteCfg.PoliceNumber
|
||||
settings.PageTitle = siteCfg.PageTitle
|
||||
settings.Favicon = siteCfg.Favicon
|
||||
settings.IconLibrary = siteCfg.IconLibrary
|
||||
settings.FontLibrary = siteCfg.FontLibrary
|
||||
settings.UmamiScript = siteCfg.UmamiScript
|
||||
settings.UmamiWebsiteID = siteCfg.UmamiWebsiteID
|
||||
|
||||
if cfg != nil {
|
||||
if err := importLegacyJSON(cfg.DataDir, settings); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
settings.Normalize()
|
||||
return db.SaveSiteSettings(ctx, settings)
|
||||
}
|
||||
|
||||
func importLegacyJSON(dataDir string, settings *config.SiteSettings) error {
|
||||
if dataDir == "" {
|
||||
return nil
|
||||
}
|
||||
var footer struct {
|
||||
Start string `json:"start"`
|
||||
End string `json:"end"`
|
||||
}
|
||||
if err := readOptionalJSON(filepath.Join(dataDir, "footer_year.json"), &footer); err != nil {
|
||||
return err
|
||||
}
|
||||
if footer.Start != "" || footer.End != "" {
|
||||
settings.FooterYearStart, settings.FooterYearEnd = footer.Start, footer.End
|
||||
}
|
||||
|
||||
var timer config.VisitTimerConfig
|
||||
if err := readOptionalJSON(filepath.Join(dataDir, "visit_timer.json"), &timer); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dataDir, "visit_timer.json")); err == nil {
|
||||
settings.ShowVisitTimer = timer.ShowVisitTimer
|
||||
}
|
||||
|
||||
var texts config.RotatingTextsConfig
|
||||
if err := readOptionalJSON(filepath.Join(dataDir, "rotating_texts.json"), &texts); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(texts.Texts) > 0 {
|
||||
settings.RotatingTexts = texts.Texts
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func readOptionalJSON(path string, target any) error {
|
||||
data, err := os.ReadFile(path)
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := json.Unmarshal(data, target); err != nil {
|
||||
return fmt.Errorf("解析 %s 失败: %w", filepath.Base(path), err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) loadSiteConfigPayload(ctx context.Context) (string, error) {
|
||||
var payload string
|
||||
err := d.SQL.QueryRowContext(ctx, "SELECT config_json FROM site_configs WHERE id = 1").Scan(&payload)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", nil
|
||||
}
|
||||
return payload, err
|
||||
}
|
||||
|
||||
func (d *Database) LoadSiteSettings(ctx context.Context) (*config.SiteSettings, error) {
|
||||
payload, err := d.loadSiteConfigPayload(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
settings := config.DefaultSiteSettings()
|
||||
if strings.TrimSpace(payload) != "" && strings.TrimSpace(payload) != "{}" {
|
||||
if err := json.Unmarshal([]byte(payload), settings); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
settings.Normalize()
|
||||
return settings, nil
|
||||
}
|
||||
|
||||
func (d *Database) SaveSiteSettings(ctx context.Context, settings *config.SiteSettings) error {
|
||||
if settings == nil {
|
||||
return errors.New("site settings cannot be nil")
|
||||
}
|
||||
settings.Normalize()
|
||||
payload, err := json.Marshal(settings)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result, err := d.SQL.ExecContext(ctx, "UPDATE site_configs SET config_json = ? WHERE id = 1", string(payload))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if affected, err := result.RowsAffected(); err != nil {
|
||||
return err
|
||||
} else if affected != 1 {
|
||||
return errors.New("site config row does not exist")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"home-vue-go/internal/config"
|
||||
)
|
||||
|
||||
func TestSiteSettingsMigrationAndBooleanPersistence(t *testing.T) {
|
||||
dataDir := t.TempDir()
|
||||
writeJSON := func(name, value string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(filepath.Join(dataDir, name), []byte(value), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
writeJSON("footer_year.json", `{"start":"2020","end":"2024"}`)
|
||||
writeJSON("visit_timer.json", `{"showVisitTimer":false}`)
|
||||
writeJSON("rotating_texts.json", `{"texts":["legacy text"]}`)
|
||||
|
||||
cfg := config.New(dataDir)
|
||||
db, err := Init(cfg.DatabasePath, cfg)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "CGO_ENABLED=0") {
|
||||
t.Skip("go-sqlite3 requires cgo for the database integration test")
|
||||
}
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
settings, err := db.LoadSiteSettings(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if settings.FooterYearStart != "2020" || settings.FooterYearEnd != "2024" || settings.ShowVisitTimer || len(settings.RotatingTexts) != 1 {
|
||||
t.Fatalf("legacy settings were not migrated: %+v", settings)
|
||||
}
|
||||
|
||||
settings.ShowAbout = false
|
||||
settings.ShowSites = false
|
||||
settings.ShowContacts = false
|
||||
settings.ShowThemeToggle = false
|
||||
settings.ShowFooter = false
|
||||
settings.AnalyticsProvider = "umami"
|
||||
settings.UmamiCredential = "v1:stored-secret"
|
||||
if err := db.SaveSiteSettings(context.Background(), settings); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
loaded, err := db.LoadSiteSettings(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if loaded.ShowAbout || loaded.ShowSites || loaded.ShowContacts || loaded.ShowThemeToggle || loaded.ShowFooter || loaded.AnalyticsProvider != "umami" || loaded.UmamiCredential != "v1:stored-secret" {
|
||||
t.Fatalf("false values or private payload were not persisted: %+v", loaded)
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,3 @@
|
||||
package ent
|
||||
|
||||
//go:generate go run -mod=mod entgo.io/ent/cmd/ent@latest generate ./schema
|
||||
//go:generate go run -mod=mod entgo.io/ent/cmd/ent@v0.14.5 generate ./schema
|
||||
|
||||
Reference in New Issue
Block a user