188 lines
5.3 KiB
Go
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
|
|
}
|