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

This commit is contained in:
2026-08-05 04:47:11 +08:00
parent 146b6b1e6c
commit ac740c24fd
35 changed files with 2876 additions and 980 deletions
+188
View File
@@ -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
}
+56
View File
@@ -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)
}
}