feat: 通用授权后台、离线证、Waffo 支付页与仪表盘加强

支持多产品计划展示、LICOFF 离线签发、支付独立配置与调试,以及分析看板;同步更新 README 与集成文档。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Logan.LiandCursor committed 2026-08-12 20:25:46 +08:00
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
}