feat: 客户关联产品并支持按产品圈选通知群体

新增客户-产品多对多关联,列表可按产品筛选并复制邮箱;发证/下单自动回填历史关联。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Logan.LiandCursor committed 2026-08-12 21:58:35 +08:00
1 parent d0e1ca3729
commit 4c46088f4f
10 files changed
+337 -89

No files matched your search

+48 -2
View File
@@ -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
}
@@ -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
+36 -25
View File
@@ -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"`
}
+1
View File
@@ -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"`
@@ -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"
}
@@ -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"),
}
}
@@ -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 {
@@ -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,
@@ -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"