feat: 产品/客户/订单批量删除;接入 GeoLite2 城市库(地理分布/激活记录/审计日志显示归属地);仪表盘趋势图美化

This commit is contained in:
Logan.Li committed 2026-09-14 22:48:14 +08:00
1 parent 252ae14aef
commit 473dcd0aab
24 files changed
+431 -34

No files matched your search

+9 -7
View File
@@ -8,7 +8,8 @@ require (
github.com/gin-gonic/gin v1.11.0 github.com/gin-gonic/gin v1.11.0
github.com/golang-jwt/jwt/v5 v5.2.0 github.com/golang-jwt/jwt/v5 v5.2.0
github.com/joho/godotenv v1.5.1 github.com/joho/godotenv v1.5.1
golang.org/x/crypto v0.43.0 github.com/oschwald/geoip2-golang/v2 v2.3.0
golang.org/x/crypto v0.54.0
golang.org/x/time v0.15.0 golang.org/x/time v0.15.0
gorm.io/driver/sqlite v1.5.4 gorm.io/driver/sqlite v1.5.4
gorm.io/gorm v1.25.5 gorm.io/gorm v1.25.5
@@ -35,17 +36,18 @@ require (
github.com/mattn/go-sqlite3 v1.14.17 // indirect github.com/mattn/go-sqlite3 v1.14.17 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/oschwald/maxminddb-golang/v2 v2.5.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/quic-go/qpack v0.5.1 // indirect github.com/quic-go/qpack v0.5.1 // indirect
github.com/quic-go/quic-go v0.55.0 // indirect github.com/quic-go/quic-go v0.55.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.0 // indirect github.com/ugorji/go/codec v1.3.0 // indirect
golang.org/x/arch v0.22.0 // indirect golang.org/x/arch v0.22.0 // indirect
golang.org/x/mod v0.29.0 // indirect golang.org/x/mod v0.38.0 // indirect
golang.org/x/net v0.46.0 // indirect golang.org/x/net v0.57.0 // indirect
golang.org/x/sync v0.17.0 // indirect golang.org/x/sync v0.22.0 // indirect
golang.org/x/sys v0.37.0 // indirect golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.30.0 // indirect golang.org/x/text v0.40.0 // indirect
golang.org/x/tools v0.38.0 // indirect golang.org/x/tools v0.48.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect google.golang.org/protobuf v1.36.10 // indirect
) )
+18 -14
View File
@@ -57,6 +57,10 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/oschwald/geoip2-golang/v2 v2.3.0 h1:hT8/BT137lPJXq0DXwGQUS228k8pEhgBRJ1B70eqyAk=
github.com/oschwald/geoip2-golang/v2 v2.3.0/go.mod h1:tHUYg65ssvQSSzSCkiFR6LWJPYOvSw/85JiBp8kXz0U=
github.com/oschwald/maxminddb-golang/v2 v2.5.0 h1:WvEHCE8HwFS5pKWhW8nvvRxNzczuRUOGBLn2L03VlEQ=
github.com/oschwald/maxminddb-golang/v2 v2.5.0/go.mod h1:EBnvLGgY+aSckqcgyfB5LPDviqaWdMZPBDwu8c2jJbs=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
@@ -82,23 +86,23 @@ go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI= golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
@@ -4,6 +4,7 @@ import (
"backend/internal/dto" "backend/internal/dto"
customerservice "backend/internal/service/customer" customerservice "backend/internal/service/customer"
"backend/utils" "backend/utils"
"fmt"
"net/http" "net/http"
"strconv" "strconv"
@@ -120,3 +121,22 @@ func (ctrl *CustomerController) UpdateStatus(c *gin.Context) {
utils.LogAction(ctrl.db, c, "update_status", "customer", uint(id), "", "", "", "切换客户状态") utils.LogAction(ctrl.db, c, "update_status", "customer", uint(id), "", "", "", "切换客户状态")
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "状态更新成功"}) c.JSON(http.StatusOK, gin.H{"code": 0, "message": "状态更新成功"})
} }
// BatchDelete 批量删除客户(名下有 License 的自动跳过) // Logan.Li
func (ctrl *CustomerController) BatchDelete(c *gin.Context) {
var req struct {
IDs []uint `json:"ids" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"})
return
}
result, err := ctrl.svc.BatchDelete(req.IDs)
if err != nil {
c.JSON(http.StatusOK, gin.H{"code": 1, "message": err.Error()})
return
}
utils.LogAction(ctrl.db, c, "batch_delete", "customer", 0, "", "", "",
fmt.Sprintf("批量删除客户 %d 条,跳过 %d 条", result.Deleted, result.Skipped))
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "批量删除完成", "data": result})
}
@@ -529,3 +529,22 @@ func (ctrl *OrderController) CheckPurchaseStatus(c *gin.Context) {
resp, _ := ctrl.svc.CheckPurchaseStatus(req.CustomerEmail, req.ProductID) resp, _ := ctrl.svc.CheckPurchaseStatus(req.CustomerEmail, req.ProductID)
c.JSON(http.StatusOK, gin.H{"code": 0, "data": resp}) c.JSON(http.StatusOK, gin.H{"code": 0, "data": resp})
} }
// BatchDelete 批量删除订单(已支付为财务凭证自动跳过) // Logan.Li
func (ctrl *OrderController) BatchDelete(c *gin.Context) {
var req struct {
IDs []uint `json:"ids" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"})
return
}
result, err := ctrl.svc.BatchDelete(req.IDs)
if err != nil {
c.JSON(http.StatusOK, gin.H{"code": 1, "message": err.Error()})
return
}
utils.LogAction(ctrl.db, c, "batch_delete", "order", 0, "", "", "",
fmt.Sprintf("批量删除订单 %d 条,跳过 %d 条", result.Deleted, result.Skipped))
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "批量删除完成", "data": result})
}
@@ -144,3 +144,22 @@ func (ctrl *ProductController) UpdateStatus(c *gin.Context) {
utils.LogAction(ctrl.db, c, "update_status", "product", uint(id), "", "", "", fmt.Sprintf("切换状态为 %d", req.Status)) utils.LogAction(ctrl.db, c, "update_status", "product", uint(id), "", "", "", fmt.Sprintf("切换状态为 %d", req.Status))
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "状态更新成功"}) c.JSON(http.StatusOK, gin.H{"code": 0, "message": "状态更新成功"})
} }
// BatchDelete 批量删除产品(被 License 引用的自动跳过) // Logan.Li
func (ctrl *ProductController) BatchDelete(c *gin.Context) {
var req struct {
IDs []uint `json:"ids" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"})
return
}
result, err := ctrl.svc.BatchDelete(req.IDs)
if err != nil {
c.JSON(http.StatusOK, gin.H{"code": 1, "message": err.Error()})
return
}
utils.LogAction(ctrl.db, c, "batch_delete", "product", 0, "", "", "",
fmt.Sprintf("批量删除产品 %d 条,跳过 %d 条", result.Deleted, result.Skipped))
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "批量删除完成", "data": result})
}
+1
View File
@@ -184,5 +184,6 @@ type AuditLogResponse struct {
Detail string `json:"detail"` Detail string `json:"detail"`
Result string `json:"result"` Result string `json:"result"`
IP string `json:"ip"` IP string `json:"ip"`
Location string `json:"location,omitempty"` // IP 归属地(城市库识别) // Logan.Li
CreatedAt string `json:"created_at"` CreatedAt string `json:"created_at"`
} }
+1
View File
@@ -119,6 +119,7 @@ type ActivationResponse struct {
DeviceName string `json:"device_name"` DeviceName string `json:"device_name"`
DeviceInfo string `json:"device_info"` DeviceInfo string `json:"device_info"`
IP string `json:"ip"` IP string `json:"ip"`
Location string `json:"location,omitempty"` // IP 归属地(国家/省/市,中文) // Logan.Li
Status string `json:"status"` Status string `json:"status"`
IsOnline bool `json:"is_online"` IsOnline bool `json:"is_online"`
ActivatedAt string `json:"activated_at"` ActivatedAt string `json:"activated_at"`
@@ -8,6 +8,8 @@ import (
"strings" "strings"
"time" "time"
"backend/utils"
"gorm.io/gorm" "gorm.io/gorm"
) )
@@ -208,7 +210,8 @@ func (s *AnalyticsService) GetProductDistribution() ([]dto.ProductDistribution,
return results, nil return results, nil
} }
// GetGeoDistribution 地理/网络分布(device_info.country 优先,否则按 IP 粗分) // Logan.Li // GetGeoDistribution 地理分布:GeoLite2 城市库按 IP 识别(国家/省/市,中文优先),
// device_info 显式声明国家时优先;识别失败回退 公网/内网/未知 // Logan.Li
func (s *AnalyticsService) GetGeoDistribution() ([]dto.GeoDistribution, error) { func (s *AnalyticsService) GetGeoDistribution() ([]dto.GeoDistribution, error) {
var acts []model.Activation var acts []model.Activation
s.db.Where("status = ?", "active").Find(&acts) s.db.Where("status = ?", "active").Find(&acts)
@@ -261,6 +264,9 @@ func regionFromActivation(a model.Activation) string {
if ip == "" { if ip == "" {
return "未知" return "未知"
} }
if loc := utils.LookupIPLocation(ip); loc != "" {
return loc
}
parsed := net.ParseIP(ip) parsed := net.ParseIP(ip)
if parsed == nil { if parsed == nil {
return "未知" return "未知"
@@ -0,0 +1,41 @@
package customer
import "backend/internal/model"
// BatchDeleteResult 批量删除结果 // Logan.Li
type BatchDeleteResult struct {
Deleted int `json:"deleted"`
Skipped int `json:"skipped"`
Failed int `json:"failed"`
SkippedIDs []uint `json:"skipped_ids,omitempty"`
}
// BatchDelete 批量删除客户:名下有 License 的跳过;其余软删除并清理产品关联 // Logan.Li
func (s *CustomerService) BatchDelete(ids []uint) (*BatchDeleteResult, error) {
result := &BatchDeleteResult{}
seen := map[uint]struct{}{}
for _, id := range ids {
if id == 0 {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
var licCount int64
s.db.Model(&model.License{}).Where("customer_id = ?", id).Count(&licCount)
if licCount > 0 {
result.Skipped++
result.SkippedIDs = append(result.SkippedIDs, id)
continue
}
if err := s.db.Delete(&model.Customer{}, id).Error; err != nil {
result.Failed++
continue
}
s.db.Where("customer_id = ?", id).Delete(&model.CustomerProduct{})
result.Deleted++
}
return result, nil
}
@@ -454,6 +454,7 @@ func toActivationResponse(a model.Activation) dto.ActivationResponse {
DeviceName: a.DeviceName, DeviceName: a.DeviceName,
DeviceInfo: a.DeviceInfo, DeviceInfo: a.DeviceInfo,
IP: a.IP, IP: a.IP,
Location: utils.LookupIPLocation(a.IP),
Status: a.Status, Status: a.Status,
IsOnline: a.IsOnline, IsOnline: a.IsOnline,
ActivatedAt: a.ActivatedAt.Format("2006-01-02 15:04:05"), ActivatedAt: a.ActivatedAt.Format("2006-01-02 15:04:05"),
+43
View File
@@ -0,0 +1,43 @@
package order
import "backend/internal/model"
// BatchDeleteResult 批量删除结果 // Logan.Li
type BatchDeleteResult struct {
Deleted int `json:"deleted"`
Skipped int `json:"skipped"`
Failed int `json:"failed"`
SkippedIDs []uint `json:"skipped_ids,omitempty"`
}
// BatchDelete 批量删除订单:已支付(paid)为财务凭证默认跳过,其余状态可删 // Logan.Li
func (s *OrderService) BatchDelete(ids []uint) (*BatchDeleteResult, error) {
result := &BatchDeleteResult{}
seen := map[uint]struct{}{}
for _, id := range ids {
if id == 0 {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
var order model.Order
if err := s.db.First(&order, id).Error; err != nil {
result.Failed++
continue
}
if order.Status == "paid" {
result.Skipped++
result.SkippedIDs = append(result.SkippedIDs, id)
continue
}
if err := s.db.Delete(&model.Order{}, id).Error; err != nil {
result.Failed++
continue
}
result.Deleted++
}
return result, nil
}
+41
View File
@@ -0,0 +1,41 @@
package product
import "backend/internal/model"
// BatchDeleteResult 批量删除结果 // Logan.Li
type BatchDeleteResult struct {
Deleted int `json:"deleted"`
Skipped int `json:"skipped"`
Failed int `json:"failed"`
SkippedIDs []uint `json:"skipped_ids,omitempty"`
}
// BatchDelete 批量删除产品:被 License 引用的跳过;未引用的连同其定价计划一并删除 // Logan.Li
func (s *ProductService) BatchDelete(ids []uint) (*BatchDeleteResult, error) {
result := &BatchDeleteResult{}
seen := map[uint]struct{}{}
for _, id := range ids {
if id == 0 {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
var licCount int64
s.db.Model(&model.License{}).Where("product_id = ?", id).Count(&licCount)
if licCount > 0 {
result.Skipped++
result.SkippedIDs = append(result.SkippedIDs, id)
continue
}
if err := s.db.Delete(&model.Product{}, id).Error; err != nil {
result.Failed++
continue
}
s.db.Where("product_id = ?", id).Delete(&model.Plan{})
result.Deleted++
}
return result, nil
}
@@ -4,6 +4,7 @@ import (
"backend/internal/dto" "backend/internal/dto"
"backend/internal/model" "backend/internal/model"
"backend/internal/service/payment" "backend/internal/service/payment"
"backend/utils"
"errors" "errors"
"fmt" "fmt"
"net/url" "net/url"
@@ -728,6 +729,7 @@ func (s *SettingService) ListAuditLogs(page, pageSize int, action, targetType st
Detail: l.Detail, Detail: l.Detail,
Result: l.Result, Result: l.Result,
IP: l.IP, IP: l.IP,
Location: utils.LookupIPLocation(l.IP),
CreatedAt: l.CreatedAt.Format("2006-01-02 15:04:05"), CreatedAt: l.CreatedAt.Format("2006-01-02 15:04:05"),
}) })
} }
+3
View File
@@ -160,6 +160,7 @@ func SetupRouter(db *gorm.DB, cfg *config.Config) *gin.Engine {
register(products, "GET", "/:id", productController.Get) register(products, "GET", "/:id", productController.Get)
register(products, "PUT", "/:id", productController.Update) register(products, "PUT", "/:id", productController.Update)
register(products, "DELETE", "/:id", productController.Delete) register(products, "DELETE", "/:id", productController.Delete)
register(products, "POST", "/batch-delete", productController.BatchDelete)
register(products, "PATCH", "/:id/status", productController.UpdateStatus) register(products, "PATCH", "/:id/status", productController.UpdateStatus)
} }
@@ -172,6 +173,7 @@ func SetupRouter(db *gorm.DB, cfg *config.Config) *gin.Engine {
register(customers, "GET", "/:id", customerController.Get) register(customers, "GET", "/:id", customerController.Get)
register(customers, "PUT", "/:id", customerController.Update) register(customers, "PUT", "/:id", customerController.Update)
register(customers, "DELETE", "/:id", customerController.Delete) register(customers, "DELETE", "/:id", customerController.Delete)
register(customers, "POST", "/batch-delete", customerController.BatchDelete)
register(customers, "PATCH", "/:id/status", customerController.UpdateStatus) register(customers, "PATCH", "/:id/status", customerController.UpdateStatus)
} }
@@ -211,6 +213,7 @@ func SetupRouter(db *gorm.DB, cfg *config.Config) *gin.Engine {
register(orders, "GET", "", orderController.List) register(orders, "GET", "", orderController.List)
register(orders, "GET", "/:id", orderController.GetDetail) register(orders, "GET", "/:id", orderController.GetDetail)
register(orders, "POST", "/manual-grant", orderController.ManualGrant) register(orders, "POST", "/manual-grant", orderController.ManualGrant)
register(orders, "POST", "/batch-delete", orderController.BatchDelete)
} }
// 客户权益管理 // 客户权益管理
+71
View File
@@ -0,0 +1,71 @@
package utils
// IP 地理位置识别:复用 opsx-waf 的 GeoLite2-City 方案(中文优先,内网标记)。
// 数据文件默认 /data/geoip/GeoLite2-City.mmdb(容器卷内),缺失时静默返回空串,不影响业务。 // Logan.Li
import (
"net/netip"
"os"
"strings"
"sync"
geoip2 "github.com/oschwald/geoip2-golang/v2"
)
var geoIPReader struct {
once sync.Once
db *geoip2.Reader
}
// LookupIPLocation 返回 "国家 / 省份 / 城市"(中文优先);内网返回"内网",无法识别返回空串 // Logan.Li
func LookupIPLocation(value string) string {
ip, err := netip.ParseAddr(strings.TrimSpace(value))
if err != nil {
return ""
}
if ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast() {
return "内网"
}
geoIPReader.once.Do(func() {
path := os.Getenv("GEOIP_DB_PATH")
if path == "" {
path = "/data/geoip/GeoLite2-City.mmdb"
}
geoIPReader.db, _ = geoip2.Open(path)
})
if geoIPReader.db == nil {
return ""
}
record, err := geoIPReader.db.City(ip)
if err != nil {
return ""
}
parts := localizedNames(record.Country.Names, record.Country.ISOCode)
if len(record.Subdivisions) > 0 {
if sub := localizedNames(record.Subdivisions[0].Names, record.Subdivisions[0].ISOCode); sub != "" && sub != parts {
parts = joinLocation(parts, sub)
}
}
if city := localizedNames(record.City.Names, ""); city != "" {
parts = joinLocation(parts, city)
}
return parts
}
// localizedNames 中文优先;GeoLite2 少数中国城市只有英文名,走兜底映射 // Logan.Li
func localizedNames(names geoip2.Names, fallback string) string {
if names.SimplifiedChinese != "" {
return names.SimplifiedChinese
}
if names.English != "" {
return names.English
}
return fallback
}
func joinLocation(left, right string) string {
if left == "" {
return right
}
return left + " / " + right
}
+5
View File
@@ -29,3 +29,8 @@ export function deleteCustomer(id) {
export function updateCustomerStatus(id, status) { export function updateCustomerStatus(id, status) {
return request({ url: `/customers/${id}/status`, method: 'patch', data: { status } }) return request({ url: `/customers/${id}/status`, method: 'patch', data: { status } })
} }
// batchDeleteCustomers 批量删除 // Logan.Li
export function batchDeleteCustomers(ids) {
return request.post('/customers/batch-delete', { ids })
}
+5
View File
@@ -13,3 +13,8 @@ export function getOrder(id) {
export function manualGrant(data) { export function manualGrant(data) {
return request({ url: '/orders/manual-grant', method: 'post', data }) return request({ url: '/orders/manual-grant', method: 'post', data })
} }
// batchDeleteOrders 批量删除 // Logan.Li
export function batchDeleteOrders(ids) {
return request.post('/orders/batch-delete', { ids })
}
+5
View File
@@ -29,3 +29,8 @@ export function deleteProduct(id) {
export function updateProductStatus(id, status) { export function updateProductStatus(id, status) {
return request({ url: `/products/${id}/status`, method: 'patch', data: { status } }) return request({ url: `/products/${id}/status`, method: 'patch', data: { status } })
} }
// batchDeleteProducts 批量删除 // Logan.Li
export function batchDeleteProducts(ids) {
return request.post('/products/batch-delete', { ids })
}
+31 -3
View File
@@ -24,7 +24,12 @@
</div> </div>
<div class="actions"> <div class="actions">
<el-button :disabled="!productFilter || list.length === 0" @click="copyEmails">复制本页邮箱</el-button> <el-button :disabled="!productFilter || list.length === 0" @click="copyEmails">复制本页邮箱</el-button>
<el-button type="primary" @click="openCreate">新增客户</el-button> <div>
<el-button type="danger" plain :disabled="!selectedRows.length" @click="batchDelete">
批量删除{{ selectedRows.length ? `(${selectedRows.length})` : '' }}
</el-button>
<el-button type="primary" @click="openCreate">新增客户</el-button>
</div>
</div> </div>
</div> </div>
@@ -36,7 +41,8 @@
style="margin-bottom: 12px" style="margin-bottom: 12px"
/> />
<el-table :data="list" v-loading="loading" stripe> <el-table :data="list" v-loading="loading" stripe @selection-change="onSelectionChange">
<el-table-column type="selection" width="48" />
<el-table-column prop="name" label="客户名称" min-width="120" /> <el-table-column prop="name" label="客户名称" min-width="120" />
<el-table-column prop="company" label="公司" min-width="120" /> <el-table-column prop="company" label="公司" min-width="120" />
<el-table-column prop="email" label="邮箱" min-width="180" /> <el-table-column prop="email" label="邮箱" min-width="180" />
@@ -107,7 +113,29 @@
<script setup> <script setup>
import { ref, reactive, onMounted } from 'vue' import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { getCustomers, createCustomer, updateCustomer, deleteCustomer, updateCustomerStatus } from '@/api/customer'
const selectedRows = ref([])
function onSelectionChange(rows) {
selectedRows.value = rows || []
}
// 批量删除:名下有 License 的客户由后端自动跳过 // Logan.Li
async function batchDelete() {
const ids = selectedRows.value.map((r) => r.id).filter(Boolean)
if (!ids.length) return
await ElMessageBox.confirm(
`已选 ${ids.length} 个客户;名下存在 License 的将自动跳过。确认继续?`,
'批量删除',
{ type: 'warning' },
)
const res = await batchDeleteCustomers(ids)
const data = res.data || {}
ElMessage.success(`删除 ${data.deleted || 0} 条,跳过 ${data.skipped || 0} 条${data.failed ? `,失败 ${data.failed}` : ''}`)
selectedRows.value = []
loadData()
}
import { getCustomers, createCustomer, updateCustomer, deleteCustomer, updateCustomerStatus, batchDeleteCustomers } from '@/api/customer'
import { getAllProducts } from '@/api/product' import { getAllProducts } from '@/api/product'
const list = ref([]) const list = ref([])
+22 -2
View File
@@ -148,15 +148,26 @@ const growthOption = computed(() => ({
{ {
name: '新增', name: '新增',
type: 'bar', type: 'bar',
barMaxWidth: 22,
itemStyle: { borderRadius: [3, 3, 0, 0], color: '#409eff' },
data: growthData.value.map((d) => d.new_count ?? d.count ?? d.value ?? 0), data: growthData.value.map((d) => d.new_count ?? d.count ?? d.value ?? 0),
color: '#409eff',
}, },
{ {
name: '累计', name: '累计',
type: 'line', type: 'line',
smooth: true, smooth: true,
showSymbol: false,
data: growthData.value.map((d) => d.total_count ?? d.total ?? null), data: growthData.value.map((d) => d.total_count ?? d.total ?? null),
color: '#67c23a', color: '#67c23a',
areaStyle: {
color: {
type: 'linear', x: 0, y: 0, x2: 0, y2: 1,
colorStops: [
{ offset: 0, color: 'rgba(103,194,58,0.28)' },
{ offset: 1, color: 'rgba(103,194,58,0.02)' },
],
},
},
}, },
], ],
})) }))
@@ -170,8 +181,17 @@ const revenueOption = computed(() => ({
data: revenueData.value.map((d) => d.amount ?? d.revenue ?? d.value ?? 0), data: revenueData.value.map((d) => d.amount ?? d.revenue ?? d.value ?? 0),
type: 'line', type: 'line',
smooth: true, smooth: true,
areaStyle: { opacity: 0.15 }, showSymbol: false,
color: '#f56c6c', color: '#f56c6c',
areaStyle: {
color: {
type: 'linear', x: 0, y: 0, x2: 0, y2: 1,
colorStops: [
{ offset: 0, color: 'rgba(245,108,108,0.30)' },
{ offset: 1, color: 'rgba(245,108,108,0.02)' },
],
},
},
}], }],
})) }))
+3
View File
@@ -131,6 +131,9 @@
<el-table-column prop="device_id" label="设备 ID" min-width="120" /> <el-table-column prop="device_id" label="设备 ID" min-width="120" />
<el-table-column prop="device_name" label="设备名" min-width="120" /> <el-table-column prop="device_name" label="设备名" min-width="120" />
<el-table-column prop="ip" label="IP" width="120" /> <el-table-column prop="ip" label="IP" width="120" />
<el-table-column label="归属地" min-width="120">
<template #default="{ row }">{{ row.location || "—" }}</template>
</el-table-column>
<el-table-column label="在线" width="70"> <el-table-column label="在线" width="70">
<template #default="{ row }"> <template #default="{ row }">
<el-tag :type="row.is_online ? 'success' : 'info'" size="small">{{ row.is_online ? '在线' : '离线' }}</el-tag> <el-tag :type="row.is_online ? 'success' : 'info'" size="small">{{ row.is_online ? '在线' : '离线' }}</el-tag>
+1
View File
@@ -27,6 +27,7 @@
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="ip" label="IP" width="130" /> <el-table-column prop="ip" label="IP" width="130" />
<el-table-column label="归属地" min-width="140"><template #default="{ row }">{{ row.location || "—" }}</template></el-table-column>
<el-table-column prop="created_at" label="时间" width="170" /> <el-table-column prop="created_at" label="时间" width="170" />
</el-table> </el-table>
+32 -4
View File
@@ -7,10 +7,16 @@
<el-option v-for="s in statusOptions" :key="s.value" :label="s.label" :value="s.value" /> <el-option v-for="s in statusOptions" :key="s.value" :label="s.label" :value="s.value" />
</el-select> </el-select>
</div> </div>
<el-button type="primary" @click="grantVisible = true">手动发放</el-button> <div>
<el-button type="danger" plain :disabled="!selectedRows.length" @click="batchDelete">
批量删除{{ selectedRows.length ? `(${selectedRows.length})` : '' }}
</el-button>
<el-button type="primary" @click="grantVisible = true">手动发放</el-button>
</div>
</div> </div>
<el-table :data="list" v-loading="loading" stripe> <el-table :data="list" v-loading="loading" stripe @selection-change="onSelectionChange">
<el-table-column type="selection" width="48" />
<el-table-column prop="order_no" label="订单号" min-width="200" /> <el-table-column prop="order_no" label="订单号" min-width="200" />
<el-table-column prop="customer_name" label="客户" min-width="100" /> <el-table-column prop="customer_name" label="客户" min-width="100" />
<el-table-column prop="product_name" label="产品" min-width="100" /> <el-table-column prop="product_name" label="产品" min-width="100" />
@@ -61,8 +67,30 @@
<script setup> <script setup>
import { ref, reactive, onMounted } from 'vue' import { ref, reactive, onMounted } from 'vue'
import { ElMessage } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { getOrders, manualGrant } from '@/api/order'
const selectedRows = ref([])
function onSelectionChange(rows) {
selectedRows.value = rows || []
}
// 批量删除:已支付订单为财务凭证,后端自动跳过 // Logan.Li
async function batchDelete() {
const ids = selectedRows.value.map((r) => r.id).filter(Boolean)
if (!ids.length) return
await ElMessageBox.confirm(
`已选 ${ids.length} 条订单;已支付(财务凭证)将自动跳过,其余删除后不可恢复。确认继续?`,
'批量删除',
{ type: 'warning' },
)
const res = await batchDeleteOrders(ids)
const data = res.data || {}
ElMessage.success(`删除 ${data.deleted || 0} 条,跳过 ${data.skipped || 0} 条${data.failed ? `,失败 ${data.failed}` : ''}`)
selectedRows.value = []
loadData()
}
import { getOrders, manualGrant, batchDeleteOrders } from '@/api/order'
import { getAllProducts } from '@/api/product' import { getAllProducts } from '@/api/product'
import request from '@/api/request' import request from '@/api/request'
+31 -3
View File
@@ -3,10 +3,16 @@
<el-card shadow="never"> <el-card shadow="never">
<div class="toolbar"> <div class="toolbar">
<el-input v-model="keyword" placeholder="搜索产品名称/编码" clearable style="width: 240px" @keyup.enter="loadData" @clear="loadData" /> <el-input v-model="keyword" placeholder="搜索产品名称/编码" clearable style="width: 240px" @keyup.enter="loadData" @clear="loadData" />
<el-button type="primary" @click="dialogVisible = true">新增产品</el-button> <div>
<el-button type="danger" plain :disabled="!selectedRows.length" @click="batchDelete">
批量删除{{ selectedRows.length ? `(${selectedRows.length})` : '' }}
</el-button>
<el-button type="primary" @click="dialogVisible = true">新增产品</el-button>
</div>
</div> </div>
<el-table :data="list" v-loading="loading" stripe> <el-table :data="list" v-loading="loading" stripe @selection-change="onSelectionChange">
<el-table-column type="selection" width="48" />
<el-table-column prop="name" label="产品名称" min-width="140" /> <el-table-column prop="name" label="产品名称" min-width="140" />
<el-table-column prop="code" label="编码" width="120" /> <el-table-column prop="code" label="编码" width="120" />
<el-table-column prop="version" label="版本" width="90" /> <el-table-column prop="version" label="版本" width="90" />
@@ -67,7 +73,29 @@
<script setup> <script setup>
import { ref, reactive } from 'vue' import { ref, reactive } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { getProducts, createProduct, updateProduct, deleteProduct, updateProductStatus } from '@/api/product'
const selectedRows = ref([])
function onSelectionChange(rows) {
selectedRows.value = rows || []
}
// 批量删除:被 License 引用的产品由后端自动跳过 // Logan.Li
async function batchDelete() {
const ids = selectedRows.value.map((r) => r.id).filter(Boolean)
if (!ids.length) return
await ElMessageBox.confirm(
`已选 ${ids.length} 个产品;被 License 引用的将自动跳过,未引用的连同其定价计划一并删除。确认继续?`,
'批量删除',
{ type: 'warning' },
)
const res = await batchDeleteProducts(ids)
const data = res.data || {}
ElMessage.success(`删除 ${data.deleted || 0} 条,跳过 ${data.skipped || 0} 条${data.failed ? `,失败 ${data.failed}` : ''}`)
selectedRows.value = []
loadData()
}
import { getProducts, createProduct, updateProduct, deleteProduct, updateProductStatus, batchDeleteProducts } from '@/api/product'
const list = ref([]) const list = ref([])
const loading = ref(false) const loading = ref(false)