Files

318 lines
10 KiB
Go
Raw Permalink 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 analytics
import (
"backend/internal/dto"
"backend/internal/model"
"encoding/json"
"net"
"strings"
"time"
"backend/utils"
"gorm.io/gorm"
)
// AnalyticsService 数据分析业务逻辑层 // Logan.Li
type AnalyticsService struct {
db *gorm.DB
}
// NewAnalyticsService 创建 service 实例 // Logan.Li
func NewAnalyticsService(db *gorm.DB) *AnalyticsService {
return &AnalyticsService{db: db}
}
// GetDashboardStats 仪表板统计 // Logan.Li
func (s *AnalyticsService) GetDashboardStats() (*dto.DashboardStats, error) {
var stats dto.DashboardStats
now := time.Now()
s.db.Model(&model.License{}).Count(&stats.TotalLicenses)
s.db.Model(&model.License{}).Where("status = ?", model.LicenseStatusActive).Count(&stats.ActiveLicenses)
// 已过期:显式 expired,或到期日已过且非永久(end < 2090) // Logan.Li
s.db.Model(&model.License{}).Where(
"status = ? OR (end_date < ? AND end_date < ? AND status NOT IN ?)",
model.LicenseStatusExpired,
now,
time.Date(2090, 1, 1, 0, 0, 0, 0, time.UTC),
[]string{model.LicenseStatusRevoked},
).Count(&stats.ExpiredLicenses)
s.db.Model(&model.License{}).Where("license_key LIKE ?", "LICOFF-%").Count(&stats.OfflineLicenses)
s.db.Model(&model.License{}).Where("license_key NOT LIKE ?", "LICOFF-%").Count(&stats.OnlineLicenses)
monthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location())
s.db.Model(&model.License{}).Where("created_at >= ?", monthStart).Count(&stats.NewThisMonth)
thirtyDaysLater := now.AddDate(0, 0, 30)
s.db.Model(&model.License{}).
Where("status = ? AND end_date > ? AND end_date < ?", model.LicenseStatusActive, now, thirtyDaysLater).
Count(&stats.ExpiringSoon)
s.db.Model(&model.Customer{}).Count(&stats.TotalCustomers)
s.db.Model(&model.Product{}).Count(&stats.TotalProducts)
fiveMinAgo := now.Add(-5 * time.Minute)
s.db.Model(&model.Activation{}).Where("is_online = ? AND last_seen_at >= ?", true, fiveMinAgo).Count(&stats.OnlineDevices)
s.db.Model(&model.Order{}).
Where("status = ? AND paid_at >= ?", "paid", monthStart).
Select("COALESCE(SUM(amount), 0)").
Scan(&stats.MonthlyRevenue)
s.db.Model(&model.Order{}).
Where("status = ?", "paid").
Select("COALESCE(SUM(amount), 0)").
Scan(&stats.TotalRevenue)
return &stats, nil
}
// GetExpiringLicenses 即将到期 License 列表 // Logan.Li
func (s *AnalyticsService) GetExpiringLicenses(days int) ([]dto.ExpiringLicenseItem, error) {
if days <= 0 {
days = 30
}
now := time.Now()
deadline := now.AddDate(0, 0, days)
var licenses []model.License
s.db.Preload("Customer").Preload("Product").
Where("status = ? AND end_date > ? AND end_date < ?", model.LicenseStatusActive, now, deadline).
Order("end_date ASC").Limit(20).Find(&licenses)
results := make([]dto.ExpiringLicenseItem, 0, len(licenses))
for _, l := range licenses {
results = append(results, dto.ExpiringLicenseItem{
ID: l.ID,
LicenseKey: shortKey(l.LicenseKey),
CustomerName: l.Customer.Name,
ProductName: l.Product.Name,
EndDate: l.EndDate.Format("2006-01-02"),
DaysLeft: int(l.EndDate.Sub(now).Hours() / 24),
})
}
return results, nil
}
func shortKey(key string) string {
key = strings.TrimSpace(key)
if strings.HasPrefix(key, "LICOFF-") && len(key) > 28 {
return key[:14] + "…" + key[len(key)-8:]
}
return key
}
// GetRecentActivities 最近操作活动 // Logan.Li
func (s *AnalyticsService) GetRecentActivities(limit int) ([]dto.RecentActivityItem, error) {
if limit <= 0 {
limit = 10
}
var logs []model.AuditLog
s.db.Order("created_at DESC").Limit(limit).Find(&logs)
results := make([]dto.RecentActivityItem, 0, len(logs))
for _, l := range logs {
results = append(results, dto.RecentActivityItem{
ID: l.ID,
Username: l.Username,
Action: l.Action,
TargetType: l.TargetType,
TargetName: l.TargetName,
Detail: l.Detail,
CreatedAt: l.CreatedAt.Format("2006-01-02 15:04:05"),
})
}
return results, nil
}
// GetOnlineStats 在线统计 // Logan.Li
func (s *AnalyticsService) GetOnlineStats() (map[string]interface{}, error) {
now := time.Now()
fiveMinAgo := now.Add(-5 * time.Minute)
var onlineDevices int64
var totalActivations int64
s.db.Model(&model.Activation{}).Where("is_online = ? AND last_seen_at >= ?", true, fiveMinAgo).Count(&onlineDevices)
s.db.Model(&model.Activation{}).Where("status = ?", "active").Count(&totalActivations)
return map[string]interface{}{
"online_devices": onlineDevices,
"total_activations": totalActivations,
"offline_devices": totalActivations - onlineDevices,
}, nil
}
// GetRevenueStats 收入统计(近 6 个月) // Logan.Li
func (s *AnalyticsService) GetRevenueStats() ([]dto.RevenuePoint, error) {
now := time.Now()
results := make([]dto.RevenuePoint, 0, 6)
for i := 5; i >= 0; i-- {
monthStart := time.Date(now.Year(), now.Month()-time.Month(i), 1, 0, 0, 0, 0, now.Location())
nextMonth := monthStart.AddDate(0, 1, 0)
var amount float64
s.db.Model(&model.Order{}).
Where("status = ? AND created_at >= ? AND created_at < ?", "paid", monthStart, nextMonth).
Select("COALESCE(SUM(amount), 0)").
Scan(&amount)
results = append(results, dto.RevenuePoint{
Period: monthStart.Format("2006-01"),
Amount: amount,
})
}
return results, nil
}
// GetLicenseGrowth License 增长趋势(近 12 个月累计) // Logan.Li
func (s *AnalyticsService) GetLicenseGrowth() ([]dto.LicenseGrowthPoint, error) {
detail, err := s.GetLicenseGrowthDetail()
if err != nil {
return nil, err
}
results := make([]dto.LicenseGrowthPoint, 0, len(detail))
for _, row := range detail {
results = append(results, dto.LicenseGrowthPoint{
Date: row["date"].(string),
Count: row["cumulative"].(int64),
})
}
return results, nil
}
// GetLicenseGrowthDetail 含当月新增的增长序列 // Logan.Li
func (s *AnalyticsService) GetLicenseGrowthDetail() ([]map[string]interface{}, error) {
now := time.Now()
results := make([]map[string]interface{}, 0, 12)
for i := 11; i >= 0; i-- {
monthStart := time.Date(now.Year(), now.Month()-time.Month(i), 1, 0, 0, 0, 0, now.Location())
nextMonth := monthStart.AddDate(0, 1, 0)
var cumulative, created int64
s.db.Model(&model.License{}).Where("created_at < ?", nextMonth).Count(&cumulative)
s.db.Model(&model.License{}).Where("created_at >= ? AND created_at < ?", monthStart, nextMonth).Count(&created)
results = append(results, map[string]interface{}{
"date": monthStart.Format("2006-01"),
"cumulative": cumulative,
"created": created,
})
}
return results, nil
}
// GetProductDistribution 产品分布 // Logan.Li
func (s *AnalyticsService) GetProductDistribution() ([]dto.ProductDistribution, error) {
var results []dto.ProductDistribution
s.db.Model(&model.License{}).
Select("products.name as product_name, count(licenses.id) as count").
Joins("LEFT JOIN products ON products.id = licenses.product_id").
Group("licenses.product_id").
Scan(&results)
return results, nil
}
// GetGeoDistribution 地理分布:GeoLite2 城市库按 IP 识别(国家/省/市,中文优先),
// device_info 显式声明国家时优先;识别失败回退 公网/内网/未知 // Logan.Li
func (s *AnalyticsService) GetGeoDistribution() ([]dto.GeoDistribution, error) {
var acts []model.Activation
s.db.Where("status = ?", "active").Find(&acts)
counts := map[string]int64{}
for _, a := range acts {
region := regionFromActivation(a)
counts[region]++
}
results := make([]dto.GeoDistribution, 0, len(counts))
for region, count := range counts {
results = append(results, dto.GeoDistribution{Region: region, Count: count})
}
return results, nil
}
// GetPlatformDistribution 客户端形态分布(web/pc/mobile) // Logan.Li
func (s *AnalyticsService) GetPlatformDistribution() ([]dto.PlatformDistribution, error) {
var acts []model.Activation
s.db.Where("status = ?", "active").Find(&acts)
counts := map[string]int64{}
for _, a := range acts {
p := platformFromActivation(a)
counts[p]++
}
order := []string{"pc", "web", "mobile", "unknown"}
results := make([]dto.PlatformDistribution, 0, len(order))
for _, p := range order {
if counts[p] > 0 {
results = append(results, dto.PlatformDistribution{Platform: p, Count: counts[p]})
}
}
for p, c := range counts {
if p == "pc" || p == "web" || p == "mobile" || p == "unknown" {
continue
}
results = append(results, dto.PlatformDistribution{Platform: p, Count: c})
}
return results, nil
}
func regionFromActivation(a model.Activation) string {
if info := parseDeviceInfo(a.DeviceInfo); info != nil {
for _, k := range []string{"country", "region", "locale", "geo"} {
if v, ok := info[k].(string); ok && strings.TrimSpace(v) != "" {
return strings.TrimSpace(v)
}
}
}
ip := strings.TrimSpace(a.IP)
if ip == "" {
return "未知"
}
if loc := utils.LookupIPLocation(ip); loc != "" {
return loc
}
parsed := net.ParseIP(ip)
if parsed == nil {
return "未知"
}
if parsed.IsLoopback() || parsed.IsPrivate() || parsed.IsLinkLocalUnicast() {
return "内网"
}
return "公网"
}
func platformFromActivation(a model.Activation) string {
if info := parseDeviceInfo(a.DeviceInfo); info != nil {
for _, k := range []string{"platform", "client", "os", "channel"} {
if v, ok := info[k].(string); ok {
p := strings.ToLower(strings.TrimSpace(v))
switch {
case strings.Contains(p, "ios"), strings.Contains(p, "android"), strings.Contains(p, "mobile"):
return "mobile"
case strings.Contains(p, "web"), strings.Contains(p, "browser"), strings.Contains(p, "h5"):
return "web"
case strings.Contains(p, "windows"), strings.Contains(p, "mac"), strings.Contains(p, "linux"),
strings.Contains(p, "desktop"), strings.Contains(p, "tauri"), strings.Contains(p, "pc"):
return "pc"
}
}
}
}
name := strings.ToLower(a.DeviceName)
switch {
case strings.Contains(name, "iphone"), strings.Contains(name, "android"):
return "mobile"
case strings.Contains(name, "opsxshell"), strings.Contains(name, "desktop"):
return "pc"
}
return "unknown"
}
func parseDeviceInfo(raw string) map[string]interface{} {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil
}
var m map[string]interface{}
if err := json.Unmarshal([]byte(raw), &m); err != nil {
return nil
}
return m
}