package handler import ( "net/http" "strings" "github.com/gin-gonic/gin" "kefu-cloud/server/internal/middleware" "kefu-cloud/server/internal/model" ) type CustomerHandler struct{} func NewCustomerHandler() *CustomerHandler { return &CustomerHandler{} } func canAccessCustomer(c *gin.Context, customerID uint) bool { if !middleware.HasPermission(c, "customer.view") { return false } if middleware.CanAccessAllData(c, "customer") { return true } var count int64 model.DB.Model(&model.Session{}). Where("tenant_id = ? AND customer_id = ? AND agent_id = ?", middleware.GetTenantID(c), customerID, middleware.GetUserID(c)). Count(&count) return count > 0 } func (h *CustomerHandler) List(c *gin.Context) { tenantID := middleware.GetTenantID(c) page, pageSize := middleware.GetPageParams(c) search := c.Query("search") status := c.Query("status") source := c.Query("source") var customers []model.Customer var total int64 query := model.DB.Where("tenant_id = ?", tenantID) if !middleware.CanAccessAllData(c, "customer") { assignedCustomers := model.DB.Model(&model.Session{}). Select("customer_id"). Where("tenant_id = ? AND agent_id = ?", tenantID, middleware.GetUserID(c)) query = query.Where("id IN (?)", assignedCustomers) } if search != "" { like := "%" + search + "%" query = query.Where( "name LIKE ? OR phone LIKE ? OR tel LIKE ? OR email LIKE ? OR wechat LIKE ? OR qq LIKE ? OR remark LIKE ?", like, like, like, like, like, like, like, ) } if status != "" { query = query.Where("status = ?", status) } if source != "" { query = query.Where("source = ?", source) } query.Model(&model.Customer{}).Count(&total) query.Order("updated_at desc").Offset((page - 1) * pageSize).Limit(pageSize).Find(&customers) middleware.JSONList(c, customers, total, page, pageSize) } func (h *CustomerHandler) Get(c *gin.Context) { tenantID := middleware.GetTenantID(c) id := c.Param("id") var customer model.Customer if err := model.DB.Where("id = ? AND tenant_id = ?", id, tenantID).First(&customer).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"code": 404, "message": "客户不存在"}) return } if !canAccessCustomer(c, customer.ID) { middleware.AuditDataAccessDenied(c, "customer", &customer.ID) c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "无权查看该客户"}) return } var sessions []model.Session sessionQuery := model.DB.Where("customer_id = ? AND tenant_id = ?", customer.ID, tenantID) if !middleware.CanAccessAllData(c, "customer") { sessionQuery = sessionQuery.Where("agent_id = ?", middleware.GetUserID(c)) } sessionQuery.Order("created_at desc").Limit(20).Find(&sessions) contacts := listCustomerContacts(customer.ID) middleware.JSON(c, gin.H{ "customer": customer, "sessions": sessions, "contacts": contacts, }) } // SaveCustomerReq 创建/更新客户(支持多手机号)。 // phones:多个手机号;第一项写入主字段 phone,全部写入 contacts。 type SaveCustomerReq struct { Name string `json:"name"` Phone string `json:"phone"` // 兼容:单手机 Phones []string `json:"phones"` // 多手机 Tel string `json:"tel"` // 联系电话/固话 Email string `json:"email"` Wechat string `json:"wechat"` QQ string `json:"qq"` Remark string `json:"remark"` Tags string `json:"tags"` Source string `json:"source"` Status string `json:"status"` } func normalizePhoneList(primary string, phones []string) []string { seen := map[string]bool{} var out []string add := func(raw string) { v := strings.TrimSpace(raw) if v == "" || seen[v] { return } seen[v] = true out = append(out, v) } add(primary) for _, p := range phones { add(p) } return out } // syncCustomerPhones 以 phones 列表为准重写 kind=phone 的 contacts(source=agent),并补主字段。 func syncCustomerPhones(tenantID, customerID uint, phones []string) { // 删除坐席维护的手机号记录后重建;自动识别(draft/message)中不在新列表的保留 model.DB.Where("customer_id = ? AND kind = ? AND source = ?", customerID, "phone", "agent").Delete(&model.CustomerContact{}) for _, p := range phones { var exists int64 model.DB.Model(&model.CustomerContact{}). Where("customer_id = ? AND kind = ? AND value = ?", customerID, "phone", p). Count(&exists) if exists > 0 { continue } _ = model.DB.Create(&model.CustomerContact{ TenantID: tenantID, CustomerID: customerID, Kind: "phone", Value: p, Source: "agent", }).Error } } func (h *CustomerHandler) Create(c *gin.Context) { var req SaveCustomerReq if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"}) return } name := strings.TrimSpace(req.Name) if name == "" || len([]rune(name)) < 2 || len([]rune(name)) > 50 { c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "客户姓名需 2-50 个字符"}) return } phones := normalizePhoneList(req.Phone, req.Phones) primaryPhone := "" if len(phones) > 0 { primaryPhone = phones[0] } status := strings.TrimSpace(req.Status) if status == "" { status = "offline" } source := strings.TrimSpace(req.Source) if source == "" { source = "手动录入" } tenantID := middleware.GetTenantID(c) tags := req.Tags if tags != "" { normalized, err := normalizeCustomerTagsForTenant(tenantID, tags, nil) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": err.Error()}) return } tags = normalized } else { tags = "[]" } customer := model.Customer{ TenantID: tenantID, Name: name, Phone: primaryPhone, Tel: strings.TrimSpace(req.Tel), Email: strings.TrimSpace(req.Email), Wechat: strings.TrimSpace(req.Wechat), QQ: strings.TrimSpace(req.QQ), Remark: strings.TrimSpace(req.Remark), Tags: tags, Source: source, Status: status, } if err := model.DB.Create(&customer).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "创建失败"}) return } syncCustomerPhones(tenantID, customer.ID, phones) // 主字段微信/邮箱/QQ 也写入 contacts(便于统一展示) var extras []extractedContact if customer.Wechat != "" { extras = append(extras, extractedContact{Kind: "wechat", Value: customer.Wechat}) } if customer.QQ != "" { extras = append(extras, extractedContact{Kind: "qq", Value: customer.QQ}) } if customer.Email != "" { extras = append(extras, extractedContact{Kind: "email", Value: customer.Email}) } if len(extras) > 0 { _, _ = mergeCustomerContacts(tenantID, customer.ID, extras, "agent") } middleware.JSON(c, customer) } func (h *CustomerHandler) Update(c *gin.Context) { tenantID := middleware.GetTenantID(c) id := c.Param("id") var customer model.Customer if err := model.DB.Where("id = ? AND tenant_id = ?", id, tenantID).First(&customer).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"code": 404, "message": "客户不存在"}) return } if !canAccessCustomer(c, customer.ID) { middleware.AuditDataAccessDenied(c, "customer", &customer.ID) c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "无权编辑该客户"}) return } var raw map[string]interface{} if err := c.ShouldBindJSON(&raw); err != nil { c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "参数错误"}) return } // 多手机号完整同步 var phones []string hasPhones := false if v, ok := raw["phones"]; ok { hasPhones = true if arr, ok := v.([]interface{}); ok { for _, item := range arr { if s, ok := item.(string); ok { phones = append(phones, s) } } } primaryExtra := "" if p, ok := raw["phone"].(string); ok { primaryExtra = p } phones = normalizePhoneList(primaryExtra, phones) delete(raw, "phones") } allowed := map[string]bool{ "name": true, "phone": true, "tel": true, "email": true, "wechat": true, "qq": true, "remark": true, "tags": true, "source": true, "status": true, } updates := map[string]interface{}{} for key, val := range raw { if !allowed[key] { continue } if s, ok := val.(string); ok { updates[key] = strings.TrimSpace(s) } else { updates[key] = val } } if hasPhones { primary := "" if len(phones) > 0 { primary = phones[0] } updates["phone"] = primary } if rawName, ok := updates["name"].(string); ok { if rawName == "" || len([]rune(rawName)) < 2 || len([]rune(rawName)) > 50 { c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "客户姓名需 2-50 个字符"}) return } } if rawTags, ok := updates["tags"]; ok { normalized, err := normalizeCustomerTagsForTenant(tenantID, rawTags, parseCustomerTagsJSON(customer.Tags)) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": err.Error()}) return } updates["tags"] = normalized } if len(updates) == 0 && !hasPhones { c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "没有可更新字段"}) return } if len(updates) > 0 { if err := model.DB.Model(&customer).Updates(updates).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "更新失败"}) return } } if hasPhones { syncCustomerPhones(tenantID, customer.ID, phones) } model.DB.First(&customer, customer.ID) middleware.JSON(c, customer) } func (h *CustomerHandler) Delete(c *gin.Context) { if !middleware.HasAnyPermission(c, "customer.export") { c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "仅主管或管理员可删除客户"}) return } tenantID := middleware.GetTenantID(c) id := c.Param("id") result := model.DB.Where("id = ? AND tenant_id = ?", id, tenantID).Delete(&model.Customer{}) if result.RowsAffected == 0 { c.JSON(http.StatusNotFound, gin.H{"code": 404, "message": "客户不存在"}) return } middleware.JSON(c, gin.H{"message": "已删除"}) }