Files
hi-server/pkg/loki/loki.go
T
shanshanzhong147 c4b2ebf7e1
Build docker and publish / build (20.15.1) (push) Has been cancelled
x
2026-05-10 10:41:50 -07:00

188 lines
5.3 KiB
Go

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
}
// GetInviteCodeMonthlyStats 获取指定邀请码在自然月维度的下载统计。
// currentTime 所在月份作为本月,上一自然月作为上月。
func (c *Client) GetInviteCodeMonthlyStats(ctx context.Context, inviteCode string, currentTime time.Time) (*InviteCodeStats, error) {
if currentTime.IsZero() {
currentTime = time.Now()
}
currentMonthStart := time.Date(currentTime.Year(), currentTime.Month(), 1, 0, 0, 0, 0, currentTime.Location())
lastMonthStart := currentMonthStart.AddDate(0, -1, 0)
thisMonthStats, err := c.queryPeriodStats(ctx, inviteCode, currentMonthStart, currentTime)
if err != nil {
return nil, fmt.Errorf("查询本月数据失败: %w", err)
}
lastMonthStats, err := c.queryPeriodStats(ctx, inviteCode, lastMonthStart, currentMonthStart)
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
}