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 }