Files
opsx-licence/backend/internal/service/analytics/analytics_service.go
T
Logan.LiandCursor 8cc2c83f9d feat: 通用授权后台、离线证、Waffo 支付页与仪表盘加强
支持多产品计划展示、LICOFF 离线签发、支付独立配置与调试,以及分析看板;同步更新 README 与集成文档。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-12 20:25:46 +08:00

312 lines
10 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 analytics
import (
"backend/internal/dto"
"backend/internal/model"
"encoding/json"
"net"
"strings"
"time"
"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 地理/网络分布(device_info.country 优先,否则按 IP 粗分) // 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 "未知"
}
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
}