使用了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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user