支持多产品计划展示、LICOFF 离线签发、支付独立配置与调试,以及分析看板;同步更新 README 与集成文档。 Co-authored-by: Cursor <cursoragent@cursor.com>
312 lines
10 KiB
Go
312 lines
10 KiB
Go
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
|
||
}
|