hi-server/pkg/loki/loki.go
2026-02-08 18:49:14 -08:00

160 lines
4.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package loki
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"regexp"
"strings"
"time"
)
// Client Loki 客户端
type Client struct {
url string
httpClient *http.Client
}
// NewClient 创建新的 Loki 客户端
// url: Loki 服务地址,例如 http://154.12.35.103:3100
func NewClient(url string) *Client {
return &Client{
url: url,
httpClient: &http.Client{
Timeout: 30 * time.Second,
},
}
}
// InviteCodeStats 邀请码统计数据
type InviteCodeStats struct {
MacClicks int64 `json:"mac_clicks"` // Mac 下载点击数
WindowsClicks int64 `json:"windows_clicks"` // Windows 下载点击数
LastMonthMac int64 `json:"last_month_mac"` // 上月 Mac 下载数
LastMonthWindows int64 `json:"last_month_windows"` // 上月 Windows 下载数
}
// LokiQueryResponse Loki 查询响应结构
type LokiQueryResponse struct {
Status string `json:"status"`
Data struct {
ResultType string `json:"resultType"`
Result []struct {
Stream map[string]string `json:"stream"`
Values [][]string `json:"values"` // [[timestamp, log_line], ...]
} `json:"result"`
} `json:"data"`
}
// GetInviteCodeStats 获取指定邀请码的下载统计
// inviteCode: 邀请码
// days: 统计天数默认30天
func (c *Client) GetInviteCodeStats(ctx context.Context, inviteCode string, days int) (*InviteCodeStats, error) {
if days <= 0 {
days = 30
}
now := time.Now().UTC()
startTime := now.Add(-time.Duration(days) * 24 * time.Hour)
// 上月时间范围
lastMonthEnd := startTime
lastMonthStart := startTime.Add(-time.Duration(days) * 24 * time.Hour)
// 查询本月数据
thisMonthStats, err := c.queryPeriodStats(ctx, inviteCode, startTime, now)
if err != nil {
return nil, fmt.Errorf("查询本月数据失败: %w", err)
}
// 查询上月数据
lastMonthStats, err := c.queryPeriodStats(ctx, inviteCode, lastMonthStart, lastMonthEnd)
if err != nil {
return nil, fmt.Errorf("查询上月数据失败: %w", err)
}
return &InviteCodeStats{
MacClicks: thisMonthStats.MacClicks,
WindowsClicks: thisMonthStats.WindowsClicks,
LastMonthMac: lastMonthStats.MacClicks,
LastMonthWindows: lastMonthStats.WindowsClicks,
}, nil
}
// queryPeriodStats 查询指定时间范围的统计数据
func (c *Client) queryPeriodStats(ctx context.Context, inviteCode string, startTime, endTime time.Time) (*InviteCodeStats, error) {
// 构建 Loki 查询
query := fmt.Sprintf(`{job="nginx_access", invite_code="%s"}`, inviteCode)
apiURL := fmt.Sprintf("%s/loki/api/v1/query_range", c.url)
params := url.Values{}
params.Add("query", query)
params.Add("start", startTime.Format(time.RFC3339))
params.Add("end", endTime.Format(time.RFC3339))
params.Add("limit", "5000")
fullURL := fmt.Sprintf("%s?%s", apiURL, params.Encode())
req, err := http.NewRequestWithContext(ctx, http.MethodGet, fullURL, nil)
if err != nil {
return nil, fmt.Errorf("创建请求失败: %w", err)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("发送请求失败: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("Loki 返回错误状态码 %d: %s", resp.StatusCode, string(body))
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("读取响应失败: %w", err)
}
var lokiResp LokiQueryResponse
if err := json.Unmarshal(body, &lokiResp); err != nil {
return nil, fmt.Errorf("解析响应失败: %w", err)
}
// 解析日志行统计 Mac 和 Windows 下载
stats := &InviteCodeStats{}
// Nginx combined log format regex
// 格式: IP - - [time] "METHOD URI VERSION" STATUS BYTES "REFERER" "UA"
logPattern := regexp.MustCompile(`"[A-Z]+ ([^ ]+) `)
for _, result := range lokiResp.Data.Result {
for _, value := range result.Values {
if len(value) < 2 {
continue
}
logLine := value[1]
// 提取 URI
matches := logPattern.FindStringSubmatch(logLine)
if len(matches) < 2 {
continue
}
uri := strings.ToLower(matches[1])
// 统计平台下载
if strings.Contains(uri, "mac") {
stats.MacClicks++
} else if strings.Contains(uri, "windows") {
stats.WindowsClicks++
}
}
}
return stats, nil
}