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 }