From 4c46088f4f5013c65e23105d4b751bdd48603e7a Mon Sep 17 00:00:00 2001 From: "Logan.Li" Date: Wed, 12 Aug 2026 21:58:35 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=A2=E6=88=B7=E5=85=B3=E8=81=94?= =?UTF-8?q?=E4=BA=A7=E5=93=81=E5=B9=B6=E6=94=AF=E6=8C=81=E6=8C=89=E4=BA=A7?= =?UTF-8?q?=E5=93=81=E5=9C=88=E9=80=89=E9=80=9A=E7=9F=A5=E7=BE=A4=E4=BD=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增客户-产品多对多关联,列表可按产品筛选并复制邮箱;发证/下单自动回填历史关联。 Co-authored-by: Cursor --- backend/database/database.go | 50 +++++- .../customer/customer_controller.go | 31 ++-- backend/internal/dto/product.go | 61 +++++--- backend/internal/model/customer.go | 1 + backend/internal/model/customer_product.go | 15 ++ .../service/customer/customer_service.go | 112 ++++++++++--- .../service/license/license_service.go | 2 + .../service/license/offline_service.go | 2 + .../internal/service/order/order_service.go | 4 + frontend/src/views/Customers.vue | 148 ++++++++++++++++-- 10 files changed, 337 insertions(+), 89 deletions(-) create mode 100644 backend/internal/model/customer_product.go diff --git a/backend/database/database.go b/backend/database/database.go index 4cf1b0d..a5a24d4 100644 --- a/backend/database/database.go +++ b/backend/database/database.go @@ -63,8 +63,9 @@ func migrateDatabase(db *gorm.DB) (*gorm.DB, error) { &model.PaymentGatewayConfig{}, // 支付网关配置 // Logan.Li // 核心授权域 &model.Product{}, // 产品 // Logan.Li - &model.Customer{}, // 客户 // Logan.Li - &model.License{}, // License // Logan.Li + &model.Customer{}, // 客户 // Logan.Li + &model.CustomerProduct{}, // 客户-产品关联 // Logan.Li + &model.License{}, // License // Logan.Li &model.Activation{}, // 激活记录 // Logan.Li // 定价与订单 &model.Plan{}, // 定价计划 // Logan.Li @@ -81,5 +82,50 @@ func migrateDatabase(db *gorm.DB) (*gorm.DB, error) { return nil, fmt.Errorf("种子数据初始化失败: %v", err) } + if err := backfillCustomerProducts(db); err != nil { + return nil, fmt.Errorf("客户产品关联回填失败: %v", err) + } + return db, nil } + +// backfillCustomerProducts 用历史 License/权益回填客户-产品关联 // Logan.Li +func backfillCustomerProducts(db *gorm.DB) error { + type pair struct { + CustomerID uint + ProductID uint + } + var pairs []pair + if err := db.Model(&model.License{}). + Select("DISTINCT customer_id, product_id"). + Where("customer_id > 0 AND product_id > 0"). + Scan(&pairs).Error; err != nil { + return err + } + var entPairs []pair + _ = db.Model(&model.CustomerEntitlement{}). + Select("DISTINCT customer_id, product_id"). + Where("customer_id > 0 AND product_id > 0"). + Scan(&entPairs) + pairs = append(pairs, entPairs...) + + seen := map[[2]uint]struct{}{} + for _, p := range pairs { + k := [2]uint{p.CustomerID, p.ProductID} + if _, ok := seen[k]; ok { + continue + } + seen[k] = struct{}{} + var n int64 + db.Model(&model.CustomerProduct{}). + Where("customer_id = ? AND product_id = ?", p.CustomerID, p.ProductID). + Count(&n) + if n > 0 { + continue + } + if err := db.Create(&model.CustomerProduct{CustomerID: p.CustomerID, ProductID: p.ProductID}).Error; err != nil { + return err + } + } + return nil +} diff --git a/backend/internal/controller/customer/customer_controller.go b/backend/internal/controller/customer/customer_controller.go index 654c8fd..816dfc8 100644 --- a/backend/internal/controller/customer/customer_controller.go +++ b/backend/internal/controller/customer/customer_controller.go @@ -1,6 +1,7 @@ package customer import ( + "backend/internal/dto" customerservice "backend/internal/service/customer" "backend/utils" "net/http" @@ -21,13 +22,14 @@ func NewCustomerController(db *gorm.DB) *CustomerController { return &CustomerController{svc: customerservice.NewCustomerService(db), db: db} } -// List 客户列表 // Logan.Li +// List 客户列表(支持 product_id 筛选通知群体) // Logan.Li func (ctrl *CustomerController) List(c *gin.Context) { page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "10")) keyword := c.Query("keyword") + productID, _ := strconv.ParseUint(c.Query("product_id"), 10, 32) - customers, total, err := ctrl.svc.List(page, pageSize, keyword) + customers, total, err := ctrl.svc.List(page, pageSize, keyword, uint(productID)) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": err.Error()}) return @@ -38,9 +40,10 @@ func (ctrl *CustomerController) List(c *gin.Context) { }}) } -// GetAll 全部客户 // Logan.Li +// GetAll 全部客户(可选 product_id) // Logan.Li func (ctrl *CustomerController) GetAll(c *gin.Context) { - customers, err := ctrl.svc.GetAll() + productID, _ := strconv.ParseUint(c.Query("product_id"), 10, 32) + customers, err := ctrl.svc.GetAll(uint(productID)) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": err.Error()}) return @@ -61,15 +64,7 @@ func (ctrl *CustomerController) Get(c *gin.Context) { // Create 创建客户 // Logan.Li func (ctrl *CustomerController) Create(c *gin.Context) { - var req struct { - Name string `json:"name" binding:"required"` - Company string `json:"company"` - Email string `json:"email" binding:"omitempty,email"` - Phone string `json:"phone"` - Address string `json:"address"` - Notes string `json:"notes"` - Status *int `json:"status"` - } + var req dto.CustomerCreate if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误: " + err.Error()}) return @@ -86,15 +81,7 @@ func (ctrl *CustomerController) Create(c *gin.Context) { // Update 更新客户 // Logan.Li func (ctrl *CustomerController) Update(c *gin.Context) { id, _ := strconv.ParseUint(c.Param("id"), 10, 32) - var req struct { - Name string `json:"name"` - Company string `json:"company"` - Email string `json:"email" binding:"omitempty,email"` - Phone string `json:"phone"` - Address string `json:"address"` - Notes string `json:"notes"` - Status *int `json:"status"` - } + var req dto.CustomerUpdate if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"}) return diff --git a/backend/internal/dto/product.go b/backend/internal/dto/product.go index 3f2ccd0..0261f0d 100644 --- a/backend/internal/dto/product.go +++ b/backend/internal/dto/product.go @@ -40,36 +40,47 @@ type ProductResponse struct { // CustomerCreate 创建客户请求 // Logan.Li type CustomerCreate struct { - Name string `json:"name" binding:"required"` - Company string `json:"company"` - Email string `json:"email" binding:"omitempty,email"` - Phone string `json:"phone"` - Address string `json:"address"` - Notes string `json:"notes"` - Status *int `json:"status"` + Name string `json:"name" binding:"required"` + Company string `json:"company"` + Email string `json:"email" binding:"omitempty,email"` + Phone string `json:"phone"` + Address string `json:"address"` + Notes string `json:"notes"` + Status *int `json:"status"` + ProductIDs []uint `json:"product_ids"` // 关联产品,便于按产品圈选通知 // Logan.Li } -// CustomerUpdate 更新客户请求 // Logan.Li +// CustomerUpdate 更新客户请求(ProductIDs 用指针区分未传与清空) // Logan.Li type CustomerUpdate struct { - Name string `json:"name"` - Company string `json:"company"` - Email string `json:"email" binding:"omitempty,email"` - Phone string `json:"phone"` - Address string `json:"address"` - Notes string `json:"notes"` - Status *int `json:"status"` + Name string `json:"name"` + Company string `json:"company"` + Email string `json:"email" binding:"omitempty,email"` + Phone string `json:"phone"` + Address string `json:"address"` + Notes string `json:"notes"` + Status *int `json:"status"` + ProductIDs *[]uint `json:"product_ids"` +} + +// CustomerProductBrief 客户关联产品简要信息 // Logan.Li +type CustomerProductBrief struct { + ID uint `json:"id"` + Name string `json:"name"` + Code string `json:"code"` } // CustomerResponse 客户响应 // Logan.Li type CustomerResponse struct { - ID uint `json:"id"` - Name string `json:"name"` - Company string `json:"company"` - Email string `json:"email"` - Phone string `json:"phone"` - Address string `json:"address"` - Notes string `json:"notes"` - Status int `json:"status"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` + ID uint `json:"id"` + Name string `json:"name"` + Company string `json:"company"` + Email string `json:"email"` + Phone string `json:"phone"` + Address string `json:"address"` + Notes string `json:"notes"` + Status int `json:"status"` + ProductIDs []uint `json:"product_ids"` + Products []CustomerProductBrief `json:"products"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` } diff --git a/backend/internal/model/customer.go b/backend/internal/model/customer.go index c45f137..9c02791 100644 --- a/backend/internal/model/customer.go +++ b/backend/internal/model/customer.go @@ -17,6 +17,7 @@ type Customer struct { Notes string `json:"notes" gorm:"type:text"` Status int `json:"status" gorm:"default:1"` // 1: 启用, 0: 禁用 Licenses []License `json:"licenses,omitempty" gorm:"foreignKey:CustomerID"` + Products []Product `json:"products,omitempty" gorm:"many2many:customer_products;"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` DeletedAt gorm.DeletedAt `json:"deleted_at,omitempty" gorm:"index"` diff --git a/backend/internal/model/customer_product.go b/backend/internal/model/customer_product.go new file mode 100644 index 0000000..e98ec7d --- /dev/null +++ b/backend/internal/model/customer_product.go @@ -0,0 +1,15 @@ +package model + +import "time" + +// CustomerProduct 客户与产品关联(用于按产品圈选通知群体) // Logan.Li +type CustomerProduct struct { + CustomerID uint `json:"customer_id" gorm:"primaryKey;autoIncrement:false"` + ProductID uint `json:"product_id" gorm:"primaryKey;autoIncrement:false"` + CreatedAt time.Time `json:"created_at"` +} + +// TableName 指定表名 // Logan.Li +func (CustomerProduct) TableName() string { + return "customer_products" +} diff --git a/backend/internal/service/customer/customer_service.go b/backend/internal/service/customer/customer_service.go index 1813ac5..9dcb0d5 100644 --- a/backend/internal/service/customer/customer_service.go +++ b/backend/internal/service/customer/customer_service.go @@ -18,15 +18,19 @@ func NewCustomerService(db *gorm.DB) *CustomerService { return &CustomerService{db: db} } -// List 分页查询客户 // Logan.Li -func (s *CustomerService) List(page, pageSize int, keyword string) ([]dto.CustomerResponse, int64, error) { +// List 分页查询客户(可按产品筛选通知群体) // Logan.Li +func (s *CustomerService) List(page, pageSize int, keyword string, productID uint) ([]dto.CustomerResponse, int64, error) { var customers []model.Customer var total int64 - query := s.db.Model(&model.Customer{}) + query := s.db.Model(&model.Customer{}).Preload("Products") if keyword != "" { query = query.Where("name LIKE ? OR email LIKE ? OR company LIKE ?", "%"+keyword+"%", "%"+keyword+"%", "%"+keyword+"%") } + if productID > 0 { + sub := s.db.Table("customer_products").Select("customer_id").Where("product_id = ?", productID) + query = query.Where("id IN (?)", sub) + } query.Count(&total) if err := query.Order("created_at DESC"). @@ -42,10 +46,15 @@ func (s *CustomerService) List(page, pageSize int, keyword string) ([]dto.Custom return results, total, nil } -// GetAll 获取全部客户(下拉选项用) // Logan.Li -func (s *CustomerService) GetAll() ([]dto.CustomerResponse, error) { +// GetAll 获取全部启用客户(下拉;可按产品过滤) // Logan.Li +func (s *CustomerService) GetAll(productID uint) ([]dto.CustomerResponse, error) { var customers []model.Customer - if err := s.db.Where("status = ?", 1).Order("created_at DESC").Find(&customers).Error; err != nil { + query := s.db.Where("status = ?", 1).Preload("Products") + if productID > 0 { + sub := s.db.Table("customer_products").Select("customer_id").Where("product_id = ?", productID) + query = query.Where("id IN (?)", sub) + } + if err := query.Order("created_at DESC").Find(&customers).Error; err != nil { return nil, err } results := make([]dto.CustomerResponse, 0, len(customers)) @@ -58,14 +67,14 @@ func (s *CustomerService) GetAll() ([]dto.CustomerResponse, error) { // GetByID 根据 ID 获取客户 // Logan.Li func (s *CustomerService) GetByID(id uint) (*dto.CustomerResponse, error) { var customer model.Customer - if err := s.db.First(&customer, id).Error; err != nil { + if err := s.db.Preload("Products").First(&customer, id).Error; err != nil { return nil, errors.New("客户不存在") } resp := toCustomerResponse(customer) return &resp, nil } -// Create 创建客户 // Logan.Li +// Create 创建客户并关联产品 // Logan.Li func (s *CustomerService) Create(req dto.CustomerCreate) (*dto.CustomerResponse, error) { status := 1 if req.Status != nil { @@ -83,11 +92,13 @@ func (s *CustomerService) Create(req dto.CustomerCreate) (*dto.CustomerResponse, if err := s.db.Create(&customer).Error; err != nil { return nil, errors.New("创建客户失败") } - resp := toCustomerResponse(customer) - return &resp, nil + if err := s.replaceProducts(customer.ID, req.ProductIDs); err != nil { + return nil, err + } + return s.GetByID(customer.ID) } -// Update 更新客户 // Logan.Li +// Update 更新客户及产品关联 // Logan.Li func (s *CustomerService) Update(id uint, req dto.CustomerUpdate) (*dto.CustomerResponse, error) { var customer model.Customer if err := s.db.First(&customer, id).Error; err != nil { @@ -109,9 +120,13 @@ func (s *CustomerService) Update(id uint, req dto.CustomerUpdate) (*dto.Customer if err := s.db.Model(&customer).Updates(updates).Error; err != nil { return nil, errors.New("更新客户失败") } - s.db.First(&customer, id) - resp := toCustomerResponse(customer) - return &resp, nil + // ProductIDs 为 nil 时不改关联;非 nil(含空切片)则全量替换 // Logan.Li + if req.ProductIDs != nil { + if err := s.replaceProducts(id, *req.ProductIDs); err != nil { + return nil, err + } + } + return s.GetByID(id) } // Delete 删除客户(软删除) // Logan.Li @@ -121,6 +136,7 @@ func (s *CustomerService) Delete(id uint) error { if count > 0 { return errors.New("该客户下存在 License,无法删除") } + _ = s.db.Where("customer_id = ?", id).Delete(&model.CustomerProduct{}).Error return s.db.Delete(&model.Customer{}, id).Error } @@ -129,18 +145,66 @@ func (s *CustomerService) UpdateStatus(id uint, status int) error { return s.db.Model(&model.Customer{}).Where("id = ?", id).Update("status", status).Error } +// AttachProduct 为客户追加产品关联(发证/下单时调用,幂等) // Logan.Li +func AttachProduct(db *gorm.DB, customerID, productID uint) error { + if db == nil || customerID == 0 || productID == 0 { + return nil + } + var n int64 + db.Model(&model.CustomerProduct{}). + Where("customer_id = ? AND product_id = ?", customerID, productID). + Count(&n) + if n > 0 { + return nil + } + return db.Create(&model.CustomerProduct{CustomerID: customerID, ProductID: productID}).Error +} + +// replaceProducts 全量替换客户产品关联 // Logan.Li +func (s *CustomerService) replaceProducts(customerID uint, productIDs []uint) error { + if err := s.db.Where("customer_id = ?", customerID).Delete(&model.CustomerProduct{}).Error; err != nil { + return errors.New("更新产品关联失败") + } + seen := map[uint]struct{}{} + for _, pid := range productIDs { + if pid == 0 { + continue + } + if _, ok := seen[pid]; ok { + continue + } + seen[pid] = struct{}{} + var product model.Product + if err := s.db.First(&product, pid).Error; err != nil { + return errors.New("关联产品不存在") + } + if err := s.db.Create(&model.CustomerProduct{CustomerID: customerID, ProductID: pid}).Error; err != nil { + return errors.New("更新产品关联失败") + } + } + return nil +} + // toCustomerResponse 模型转响应 DTO // Logan.Li func toCustomerResponse(c model.Customer) dto.CustomerResponse { + products := make([]dto.CustomerProductBrief, 0, len(c.Products)) + ids := make([]uint, 0, len(c.Products)) + for _, p := range c.Products { + products = append(products, dto.CustomerProductBrief{ID: p.ID, Name: p.Name, Code: p.Code}) + ids = append(ids, p.ID) + } return dto.CustomerResponse{ - ID: c.ID, - Name: c.Name, - Company: c.Company, - Email: c.Email, - Phone: c.Phone, - Address: c.Address, - Notes: c.Notes, - Status: c.Status, - CreatedAt: c.CreatedAt.Format("2006-01-02 15:04:05"), - UpdatedAt: c.UpdatedAt.Format("2006-01-02 15:04:05"), + ID: c.ID, + Name: c.Name, + Company: c.Company, + Email: c.Email, + Phone: c.Phone, + Address: c.Address, + Notes: c.Notes, + Status: c.Status, + ProductIDs: ids, + Products: products, + CreatedAt: c.CreatedAt.Format("2006-01-02 15:04:05"), + UpdatedAt: c.UpdatedAt.Format("2006-01-02 15:04:05"), } } diff --git a/backend/internal/service/license/license_service.go b/backend/internal/service/license/license_service.go index ffa6715..90e96de 100644 --- a/backend/internal/service/license/license_service.go +++ b/backend/internal/service/license/license_service.go @@ -3,6 +3,7 @@ package license import ( "backend/internal/dto" "backend/internal/model" + customersvc "backend/internal/service/customer" "backend/utils" "encoding/json" "errors" @@ -137,6 +138,7 @@ func (s *LicenseService) Create(req dto.LicenseCreate) ([]dto.LicenseResponse, e if err := s.db.Create(&licenses).Error; err != nil { return nil, errors.New("创建 License 失败") } + _ = customersvc.AttachProduct(s.db, req.CustomerID, req.ProductID) results := make([]dto.LicenseResponse, 0, len(licenses)) for _, l := range licenses { diff --git a/backend/internal/service/license/offline_service.go b/backend/internal/service/license/offline_service.go index 2fd7fbc..19acba5 100644 --- a/backend/internal/service/license/offline_service.go +++ b/backend/internal/service/license/offline_service.go @@ -4,6 +4,7 @@ import ( "backend/config" "backend/internal/dto" "backend/internal/model" + customersvc "backend/internal/service/customer" "backend/utils" "errors" "fmt" @@ -139,6 +140,7 @@ func (s *OfflineIssuer) Issue(req dto.OfflineLicenseCreate) (*dto.OfflineLicense if err := s.db.Create(&lic).Error; err != nil { return nil, errors.New("保存离线 License 失败") } + _ = customersvc.AttachProduct(s.db, customer.ID, product.ID) activation := model.Activation{ LicenseID: lic.ID, diff --git a/backend/internal/service/order/order_service.go b/backend/internal/service/order/order_service.go index 80ee58d..4c5149b 100644 --- a/backend/internal/service/order/order_service.go +++ b/backend/internal/service/order/order_service.go @@ -3,6 +3,7 @@ package order import ( "backend/internal/dto" "backend/internal/model" + customersvc "backend/internal/service/customer" "backend/internal/service/payment" "backend/utils" "errors" @@ -142,6 +143,7 @@ func (s *OrderService) CreateOrder(req dto.CreateOrderRequest, clientIP, userAge if err := s.db.Create(&order).Error; err != nil { return nil, errors.New("创建订单失败") } + _ = customersvc.AttachProduct(s.db, customer.ID, product.ID) // 6. 生成收银台地址 // Logan.Li var checkoutURL string @@ -365,6 +367,7 @@ func (s *OrderService) PaymentWebhook(req dto.PaymentWebhookRequest, clientIP st if err := tx.Create(&lic).Error; err != nil { return err } + _ = customersvc.AttachProduct(tx, order.CustomerID, order.ProductID) if err := tx.Model(&model.Order{}).Where("id = ?", order.ID). Update("license_id", lic.ID).Error; err != nil { return err @@ -471,6 +474,7 @@ func (s *OrderService) ManualGrant(req dto.ManualGrantRequest, operatorID uint, if err := s.db.Create(&lic).Error; err != nil { return nil, errors.New("创建 License 失败") } + _ = customersvc.AttachProduct(s.db, customer.ID, req.ProductID) // 创建/更新权益记录 // Logan.Li renewable := plan.PlanType != "perpetual" && plan.PlanType != "lifetime" diff --git a/frontend/src/views/Customers.vue b/frontend/src/views/Customers.vue index 6abd7c6..b0cefa9 100644 --- a/frontend/src/views/Customers.vue +++ b/frontend/src/views/Customers.vue @@ -2,14 +2,57 @@
- - 新增客户 +
+ + + + +
+
+ 复制本页邮箱 + 新增客户 +
+ + - + + + +