feat: 通用授权后台、离线证、Waffo 支付页与仪表盘加强
支持多产品计划展示、LICOFF 离线签发、支付独立配置与调试,以及分析看板;同步更新 README 与集成文档。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
0966375076
commit
8cc2c83f9d
41 files changed
+3177
-332
No files matched your search
@@ -3,6 +3,9 @@ package analytics
|
||||
import (
|
||||
"backend/internal/dto"
|
||||
"backend/internal/model"
|
||||
"encoding/json"
|
||||
"net"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
@@ -21,16 +24,26 @@ func NewAnalyticsService(db *gorm.DB) *AnalyticsService {
|
||||
// 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)
|
||||
|
||||
// 本月新增 // Logan.Li
|
||||
now := time.Now()
|
||||
// 已过期:显式 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)
|
||||
|
||||
// 即将到期(30 天内) // Logan.Li
|
||||
thirtyDaysLater := now.AddDate(0, 0, 30)
|
||||
s.db.Model(&model.License{}).
|
||||
Where("status = ? AND end_date > ? AND end_date < ?", model.LicenseStatusActive, now, thirtyDaysLater).
|
||||
@@ -39,15 +52,17 @@ func (s *AnalyticsService) GetDashboardStats() (*dto.DashboardStats, error) {
|
||||
s.db.Model(&model.Customer{}).Count(&stats.TotalCustomers)
|
||||
s.db.Model(&model.Product{}).Count(&stats.TotalProducts)
|
||||
|
||||
// 在线设备(5 分钟内有心跳) // Logan.Li
|
||||
fiveMinAgo := now.Add(-5 * time.Minute)
|
||||
s.db.Model(&model.Activation{}).Where("is_online = ? AND last_seen_at >= ?", true, fiveMinAgo).Count(&stats.OnlineDevices)
|
||||
|
||||
// 本月收入 // Logan.Li
|
||||
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
|
||||
}
|
||||
@@ -69,7 +84,7 @@ func (s *AnalyticsService) GetExpiringLicenses(days int) ([]dto.ExpiringLicenseI
|
||||
for _, l := range licenses {
|
||||
results = append(results, dto.ExpiringLicenseItem{
|
||||
ID: l.ID,
|
||||
LicenseKey: l.LicenseKey,
|
||||
LicenseKey: shortKey(l.LicenseKey),
|
||||
CustomerName: l.Customer.Name,
|
||||
ProductName: l.Product.Name,
|
||||
EndDate: l.EndDate.Format("2006-01-02"),
|
||||
@@ -79,6 +94,14 @@ func (s *AnalyticsService) GetExpiringLicenses(days int) ([]dto.ExpiringLicenseI
|
||||
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 {
|
||||
@@ -113,9 +136,9 @@ func (s *AnalyticsService) GetOnlineStats() (map[string]interface{}, error) {
|
||||
s.db.Model(&model.Activation{}).Where("status = ?", "active").Count(&totalActivations)
|
||||
|
||||
return map[string]interface{}{
|
||||
"online_devices": onlineDevices,
|
||||
"total_activations": totalActivations,
|
||||
"offline_devices": totalActivations - onlineDevices,
|
||||
"online_devices": onlineDevices,
|
||||
"total_activations": totalActivations,
|
||||
"offline_devices": totalActivations - onlineDevices,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -139,17 +162,36 @@ func (s *AnalyticsService) GetRevenueStats() ([]dto.RevenuePoint, error) {
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// GetLicenseGrowth License 增长趋势(近 6 个月) // Logan.Li
|
||||
// GetLicenseGrowth License 增长趋势(近 12 个月累计) // Logan.Li
|
||||
func (s *AnalyticsService) GetLicenseGrowth() ([]dto.LicenseGrowthPoint, error) {
|
||||
now := time.Now()
|
||||
results := make([]dto.LicenseGrowthPoint, 0, 6)
|
||||
for i := 5; i >= 0; i-- {
|
||||
monthEnd := time.Date(now.Year(), now.Month()-time.Month(i)+1, 0, 23, 59, 59, 0, now.Location())
|
||||
var count int64
|
||||
s.db.Model(&model.License{}).Where("created_at <= ?", monthEnd).Count(&count)
|
||||
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: monthEnd.Format("2006-01"),
|
||||
Count: count,
|
||||
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
|
||||
@@ -165,3 +207,105 @@ func (s *AnalyticsService) GetProductDistribution() ([]dto.ProductDistribution,
|
||||
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
|
||||
}
|
||||
Reference in new issue
Block a user