diff --git a/backend/go.mod b/backend/go.mod index 3d50543..63626b2 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -8,7 +8,8 @@ require ( github.com/gin-gonic/gin v1.11.0 github.com/golang-jwt/jwt/v5 v5.2.0 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 gorm.io/driver/sqlite v1.5.4 gorm.io/gorm v1.25.5 @@ -35,17 +36,18 @@ require ( github.com/mattn/go-sqlite3 v1.14.17 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // 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/quic-go/qpack v0.5.1 // indirect github.com/quic-go/quic-go v0.55.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.0 // indirect golang.org/x/arch v0.22.0 // indirect - golang.org/x/mod v0.29.0 // indirect - golang.org/x/net v0.46.0 // indirect - golang.org/x/sync v0.17.0 // indirect - golang.org/x/sys v0.37.0 // indirect - golang.org/x/text v0.30.0 // indirect - golang.org/x/tools v0.38.0 // indirect + golang.org/x/mod v0.38.0 // indirect + golang.org/x/net v0.57.0 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.40.0 // indirect + golang.org/x/tools v0.48.0 // indirect google.golang.org/protobuf v1.36.10 // indirect ) diff --git a/backend/go.sum b/backend/go.sum index 67fc39c..b9684c9 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -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/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= 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/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= 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= 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/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= -golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= -golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= -golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= -golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= -golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= -golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= -golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= +golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= +golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= +golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= +golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +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.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= -golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= -golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +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/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= -golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= -golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= +golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= +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/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/backend/internal/controller/customer/customer_controller.go b/backend/internal/controller/customer/customer_controller.go index 816dfc8..7d500d5 100644 --- a/backend/internal/controller/customer/customer_controller.go +++ b/backend/internal/controller/customer/customer_controller.go @@ -4,6 +4,7 @@ import ( "backend/internal/dto" customerservice "backend/internal/service/customer" "backend/utils" + "fmt" "net/http" "strconv" @@ -120,3 +121,22 @@ func (ctrl *CustomerController) UpdateStatus(c *gin.Context) { utils.LogAction(ctrl.db, c, "update_status", "customer", uint(id), "", "", "", "切换客户状态") 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}) +} diff --git a/backend/internal/controller/order/order_controller.go b/backend/internal/controller/order/order_controller.go index d9f61e9..4fdf073 100644 --- a/backend/internal/controller/order/order_controller.go +++ b/backend/internal/controller/order/order_controller.go @@ -529,3 +529,22 @@ func (ctrl *OrderController) CheckPurchaseStatus(c *gin.Context) { resp, _ := ctrl.svc.CheckPurchaseStatus(req.CustomerEmail, req.ProductID) 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}) +} diff --git a/backend/internal/controller/product/product_controller.go b/backend/internal/controller/product/product_controller.go index e714f9a..aaeee18 100644 --- a/backend/internal/controller/product/product_controller.go +++ b/backend/internal/controller/product/product_controller.go @@ -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)) 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}) +} diff --git a/backend/internal/dto/analytics.go b/backend/internal/dto/analytics.go index a8a3a23..70889de 100644 --- a/backend/internal/dto/analytics.go +++ b/backend/internal/dto/analytics.go @@ -184,5 +184,6 @@ type AuditLogResponse struct { Detail string `json:"detail"` Result string `json:"result"` IP string `json:"ip"` + Location string `json:"location,omitempty"` // IP 归属地(城市库识别) // Logan.Li CreatedAt string `json:"created_at"` } diff --git a/backend/internal/dto/license.go b/backend/internal/dto/license.go index 52040a5..decf1fc 100644 --- a/backend/internal/dto/license.go +++ b/backend/internal/dto/license.go @@ -119,6 +119,7 @@ type ActivationResponse struct { DeviceName string `json:"device_name"` DeviceInfo string `json:"device_info"` IP string `json:"ip"` + Location string `json:"location,omitempty"` // IP 归属地(国家/省/市,中文) // Logan.Li Status string `json:"status"` IsOnline bool `json:"is_online"` ActivatedAt string `json:"activated_at"` diff --git a/backend/internal/service/analytics/analytics_service.go b/backend/internal/service/analytics/analytics_service.go index 53a61c0..c6d112a 100644 --- a/backend/internal/service/analytics/analytics_service.go +++ b/backend/internal/service/analytics/analytics_service.go @@ -8,6 +8,8 @@ import ( "strings" "time" + "backend/utils" + "gorm.io/gorm" ) @@ -208,7 +210,8 @@ func (s *AnalyticsService) GetProductDistribution() ([]dto.ProductDistribution, return results, nil } -// GetGeoDistribution 地理/网络分布(device_info.country 优先,否则按 IP 粗分) // Logan.Li +// GetGeoDistribution 地理分布:GeoLite2 城市库按 IP 识别(国家/省/市,中文优先), +// device_info 显式声明国家时优先;识别失败回退 公网/内网/未知 // Logan.Li func (s *AnalyticsService) GetGeoDistribution() ([]dto.GeoDistribution, error) { var acts []model.Activation s.db.Where("status = ?", "active").Find(&acts) @@ -261,6 +264,9 @@ func regionFromActivation(a model.Activation) string { if ip == "" { return "未知" } + if loc := utils.LookupIPLocation(ip); loc != "" { + return loc + } parsed := net.ParseIP(ip) if parsed == nil { return "未知" diff --git a/backend/internal/service/customer/batch.go b/backend/internal/service/customer/batch.go new file mode 100644 index 0000000..9e64d6a --- /dev/null +++ b/backend/internal/service/customer/batch.go @@ -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 +} diff --git a/backend/internal/service/license/license_service.go b/backend/internal/service/license/license_service.go index d3e5be8..835f336 100644 --- a/backend/internal/service/license/license_service.go +++ b/backend/internal/service/license/license_service.go @@ -454,6 +454,7 @@ func toActivationResponse(a model.Activation) dto.ActivationResponse { DeviceName: a.DeviceName, DeviceInfo: a.DeviceInfo, IP: a.IP, + Location: utils.LookupIPLocation(a.IP), Status: a.Status, IsOnline: a.IsOnline, ActivatedAt: a.ActivatedAt.Format("2006-01-02 15:04:05"), diff --git a/backend/internal/service/order/batch.go b/backend/internal/service/order/batch.go new file mode 100644 index 0000000..e54c699 --- /dev/null +++ b/backend/internal/service/order/batch.go @@ -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 +} diff --git a/backend/internal/service/product/batch.go b/backend/internal/service/product/batch.go new file mode 100644 index 0000000..905e6b9 --- /dev/null +++ b/backend/internal/service/product/batch.go @@ -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 +} diff --git a/backend/internal/service/setting/setting_service.go b/backend/internal/service/setting/setting_service.go index 8a11a5b..672ef49 100644 --- a/backend/internal/service/setting/setting_service.go +++ b/backend/internal/service/setting/setting_service.go @@ -4,6 +4,7 @@ import ( "backend/internal/dto" "backend/internal/model" "backend/internal/service/payment" + "backend/utils" "errors" "fmt" "net/url" @@ -728,6 +729,7 @@ func (s *SettingService) ListAuditLogs(page, pageSize int, action, targetType st Detail: l.Detail, Result: l.Result, IP: l.IP, + Location: utils.LookupIPLocation(l.IP), CreatedAt: l.CreatedAt.Format("2006-01-02 15:04:05"), }) } diff --git a/backend/router/router.go b/backend/router/router.go index a7a4a0c..afe7ee0 100644 --- a/backend/router/router.go +++ b/backend/router/router.go @@ -160,6 +160,7 @@ func SetupRouter(db *gorm.DB, cfg *config.Config) *gin.Engine { register(products, "GET", "/:id", productController.Get) register(products, "PUT", "/:id", productController.Update) register(products, "DELETE", "/:id", productController.Delete) + register(products, "POST", "/batch-delete", productController.BatchDelete) 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, "PUT", "/:id", customerController.Update) register(customers, "DELETE", "/:id", customerController.Delete) + register(customers, "POST", "/batch-delete", customerController.BatchDelete) 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", "/:id", orderController.GetDetail) register(orders, "POST", "/manual-grant", orderController.ManualGrant) + register(orders, "POST", "/batch-delete", orderController.BatchDelete) } // 客户权益管理 diff --git a/backend/utils/geoip.go b/backend/utils/geoip.go new file mode 100644 index 0000000..e1a1b38 --- /dev/null +++ b/backend/utils/geoip.go @@ -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 +} diff --git a/frontend/src/api/customer.js b/frontend/src/api/customer.js index 2e5313a..defd4cc 100644 --- a/frontend/src/api/customer.js +++ b/frontend/src/api/customer.js @@ -29,3 +29,8 @@ export function deleteCustomer(id) { export function updateCustomerStatus(id, 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 }) +} diff --git a/frontend/src/api/order.js b/frontend/src/api/order.js index e8d3fa9..f128fac 100644 --- a/frontend/src/api/order.js +++ b/frontend/src/api/order.js @@ -13,3 +13,8 @@ export function getOrder(id) { export function manualGrant(data) { return request({ url: '/orders/manual-grant', method: 'post', data }) } + +// batchDeleteOrders 批量删除 // Logan.Li +export function batchDeleteOrders(ids) { + return request.post('/orders/batch-delete', { ids }) +} diff --git a/frontend/src/api/product.js b/frontend/src/api/product.js index e6537a3..8330f1c 100644 --- a/frontend/src/api/product.js +++ b/frontend/src/api/product.js @@ -29,3 +29,8 @@ export function deleteProduct(id) { export function updateProductStatus(id, 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 }) +} diff --git a/frontend/src/views/Customers.vue b/frontend/src/views/Customers.vue index b0cefa9..b0a386b 100644 --- a/frontend/src/views/Customers.vue +++ b/frontend/src/views/Customers.vue @@ -24,7 +24,12 @@
复制本页邮箱 - 新增客户 +
+ + 批量删除{{ selectedRows.length ? `(${selectedRows.length})` : '' }} + + 新增客户 +
@@ -36,7 +41,8 @@ style="margin-bottom: 12px" /> - + + @@ -107,7 +113,29 @@