package handler import ( "encoding/csv" "encoding/json" "fmt" "io" "net/http" "strconv" "strings" "sync" "time" "spider/internal/model" "spider/internal/store" "github.com/gin-gonic/gin" ) // MerchantHandler handles merchant queries. type MerchantHandler struct { store *store.Store statsCache *statsCache } // statsCache provides a simple time-based cache for stats. type statsCache struct { mu sync.Mutex data []byte expiresAt time.Time } func (sc *statsCache) get() (gin.H, bool) { if sc == nil { return nil, false } sc.mu.Lock() defer sc.mu.Unlock() if time.Now().Before(sc.expiresAt) && sc.data != nil { var result gin.H json.Unmarshal(sc.data, &result) return result, true } return nil, false } func (sc *statsCache) set(data gin.H) { if sc == nil { return } sc.mu.Lock() defer sc.mu.Unlock() sc.data, _ = json.Marshal(data) sc.expiresAt = time.Now().Add(30 * time.Second) } // Stats returns aggregate statistics for merchants (cached 30s). func (h *MerchantHandler) Stats(c *gin.Context) { if h.statsCache == nil { h.statsCache = &statsCache{} } if cached, ok := h.statsCache.get(); ok { OK(c, cached) return } var rawTotal int64 h.store.DB.Model(&model.MerchantRaw{}).Count(&rawTotal) var cleanTotal int64 h.store.DB.Model(&model.MerchantClean{}).Count(&cleanTotal) // Count by status var statusRows []struct { Status string Cnt int64 } h.store.DB.Model(&model.MerchantClean{}). Select("status, count(*) as cnt"). Group("status"). Scan(&statusRows) byStatus := map[string]int64{} for _, r := range statusRows { byStatus[r.Status] = r.Cnt } // Count by level var levelRows []struct { Level string Cnt int64 } h.store.DB.Model(&model.MerchantClean{}). Select("level, count(*) as cnt"). Group("level"). Scan(&levelRows) byLevel := map[string]int64{} for _, r := range levelRows { byLevel[r.Level] = r.Cnt } // Count by source_type var sourceRows []struct { SourceType string Cnt int64 } h.store.DB.Model(&model.MerchantRaw{}). Select("source_type, count(*) as cnt"). Group("source_type"). Scan(&sourceRows) bySource := map[string]int64{} for _, r := range sourceRows { bySource[r.SourceType] = r.Cnt } result := gin.H{ "raw_total": rawTotal, "clean_total": cleanTotal, "by_status": byStatus, "by_level": byLevel, "by_source": bySource, } h.statsCache.set(result) OK(c, result) } // ListRaw returns raw merchants with filters and pagination. func (h *MerchantHandler) ListRaw(c *gin.Context) { page, pageSize, offset := parsePage(c) query := h.store.DB.Model(&model.MerchantRaw{}) if status := c.Query("status"); status != "" { query = query.Where("status = ?", status) } if sourceType := c.Query("source_type"); sourceType != "" { query = query.Where("source_type = ?", sourceType) } if search := c.Query("search"); search != "" { like := "%" + search + "%" query = query.Where("tg_username LIKE ? OR merchant_name LIKE ?", like, like) } var total int64 query.Count(&total) var items []model.MerchantRaw if err := query.Order("created_at DESC").Limit(pageSize).Offset(offset).Find(&items).Error; err != nil { Fail(c, 500, err.Error()) return } PageOK(c, items, total, page, pageSize) } // ListClean returns clean merchants with filters and pagination. func (h *MerchantHandler) ListClean(c *gin.Context) { page, pageSize, offset := parsePage(c) query := h.store.DB.Model(&model.MerchantClean{}) if status := c.Query("status"); status != "" { query = query.Where("status = ?", status) } if level := c.Query("level"); level != "" { query = query.Where("level = ?", level) } if industry := c.Query("industry_tag"); industry != "" { query = query.Where("industry_tag = ?", industry) } if followStatus := c.Query("follow_status"); followStatus != "" { query = query.Where("follow_status = ?", followStatus) } if assignedTo := c.Query("assigned_to"); assignedTo != "" { if assignedTo == "__unassigned__" { query = query.Where("assigned_to = '' OR assigned_to IS NULL") } else { query = query.Where("assigned_to = ?", assignedTo) } } if search := c.Query("search"); search != "" { like := "%" + search + "%" query = query.Where("tg_username LIKE ? OR merchant_name LIKE ?", like, like) } if hasContact := c.Query("has_contact"); hasContact == "1" { query = query.Where("website != '' OR email != '' OR phone != ''") } sortField := c.DefaultQuery("sort", "created_at") allowedSort := map[string]bool{ "created_at": true, "updated_at": true, "source_count": true, "level": true, } if !allowedSort[sortField] { sortField = "created_at" } order := c.DefaultQuery("order", "desc") if order != "asc" && order != "desc" { order = "desc" } var total int64 query.Count(&total) var items []model.MerchantClean if err := query.Order(sortField + " " + order).Limit(pageSize).Offset(offset).Find(&items).Error; err != nil { Fail(c, 500, err.Error()) return } PageOK(c, items, total, page, pageSize) } // ExportCSV exports clean merchants as CSV. func (h *MerchantHandler) ExportCSV(c *gin.Context) { query := h.store.DB.Model(&model.MerchantClean{}) if status := c.Query("status"); status != "" { query = query.Where("status = ?", status) } else { query = query.Where("status = ?", "valid") } if level := c.Query("level"); level != "" { query = query.Where("level = ?", level) } if followStatus := c.Query("follow_status"); followStatus != "" { query = query.Where("follow_status = ?", followStatus) } if assignedTo := c.Query("assigned_to"); assignedTo != "" { if assignedTo == "__unassigned__" { query = query.Where("assigned_to = '' OR assigned_to IS NULL") } else { query = query.Where("assigned_to = ?", assignedTo) } } if industryTag := c.Query("industry_tag"); industryTag != "" { query = query.Where("industry_tag = ?", industryTag) } if search := c.Query("search"); search != "" { like := "%" + search + "%" query = query.Where("tg_username LIKE ? OR merchant_name LIKE ?", like, like) } if hasContact := c.Query("has_contact"); hasContact == "1" { query = query.Where("website != '' OR email != '' OR phone != ''") } // Cap export at 50000 rows to prevent OOM var merchants []model.MerchantClean query.Order("level ASC, created_at DESC").Limit(50000).Find(&merchants) c.Header("Content-Type", "text/csv; charset=utf-8") c.Header("Content-Disposition", "attachment; filename=merchants.csv") // Write BOM for Excel compatibility c.Writer.Write([]byte{0xEF, 0xBB, 0xBF}) w := csv.NewWriter(c.Writer) w.Write([]string{"商户名", "TG用户名", "TG链接", "网站", "邮箱", "电话", "行业", "等级", "跟进状态", "负责人", "来源数", "备注"}) for _, m := range merchants { w.Write([]string{ m.MerchantName, m.TgUsername, m.TgLink, m.Website, m.Email, m.Phone, m.IndustryTag, m.Level, m.FollowStatus, m.AssignedTo, fmt.Sprintf("%d", m.SourceCount), m.Remark, }) } w.Flush() } // BatchDeleteRaw handles DELETE /merchants/raw/batch func (h *MerchantHandler) BatchDeleteRaw(c *gin.Context) { var body struct { IDs []uint `json:"ids" binding:"required"` } if err := c.ShouldBindJSON(&body); err != nil { Fail(c, 400, err.Error()) return } if err := h.store.DB.Where("id IN ?", body.IDs).Delete(&model.MerchantRaw{}).Error; err != nil { Fail(c, 500, err.Error()) return } OK(c, gin.H{"deleted": len(body.IDs)}) } // BatchDeleteClean handles DELETE /merchants/clean/batch func (h *MerchantHandler) BatchDeleteClean(c *gin.Context) { var body struct { IDs []uint `json:"ids" binding:"required"` } if err := c.ShouldBindJSON(&body); err != nil { Fail(c, 400, err.Error()) return } if err := h.store.DB.Where("id IN ?", body.IDs).Delete(&model.MerchantClean{}).Error; err != nil { Fail(c, 500, err.Error()) return } LogAudit(h.store, c, "delete", "merchant", fmt.Sprintf("batch:%d", len(body.IDs)), gin.H{"ids": body.IDs}) OK(c, gin.H{"deleted": len(body.IDs)}) } // GetByID fetches a merchant by ID. func (h *MerchantHandler) GetByID(c *gin.Context) { id, err := strconv.ParseUint(c.Param("id"), 10, 64) if err != nil { Fail(c, http.StatusBadRequest, "invalid id") return } var clean model.MerchantClean if err := h.store.DB.First(&clean, id).Error; err == nil { OK(c, gin.H{"source": "clean", "data": clean}) return } var raw model.MerchantRaw if err := h.store.DB.First(&raw, id).Error; err == nil { OK(c, gin.H{"source": "raw", "data": raw}) return } Fail(c, 404, "merchant not found") } // UpdateClean handles PUT /merchants/clean/:id func (h *MerchantHandler) UpdateClean(c *gin.Context) { id, err := strconv.ParseUint(c.Param("id"), 10, 64) if err != nil { Fail(c, 400, "invalid id") return } var body struct { MerchantName *string `json:"merchant_name"` IndustryTag *string `json:"industry_tag"` Website *string `json:"website"` Email *string `json:"email"` Phone *string `json:"phone"` Remark *string `json:"remark"` AssignedTo *string `json:"assigned_to"` } if err := c.ShouldBindJSON(&body); err != nil { Fail(c, 400, err.Error()) return } updates := map[string]interface{}{} if body.MerchantName != nil { updates["merchant_name"] = *body.MerchantName } if body.IndustryTag != nil { updates["industry_tag"] = *body.IndustryTag } if body.Website != nil { updates["website"] = *body.Website } if body.Email != nil { updates["email"] = *body.Email } if body.Phone != nil { updates["phone"] = *body.Phone } if body.Remark != nil { updates["remark"] = *body.Remark } if body.AssignedTo != nil { updates["assigned_to"] = *body.AssignedTo } if len(updates) == 0 { Fail(c, 400, "no fields to update") return } var merchant model.MerchantClean if err := h.store.DB.First(&merchant, id).Error; err != nil { Fail(c, 404, "merchant not found") return } if err := h.store.DB.Model(&merchant).Updates(updates).Error; err != nil { Fail(c, 500, err.Error()) return } h.store.DB.First(&merchant, id) LogAudit(h.store, c, "update", "merchant", fmt.Sprintf("%d", id), updates) OK(c, merchant) } // UpdateFollowStatus handles PUT /merchants/clean/:id/follow-status func (h *MerchantHandler) UpdateFollowStatus(c *gin.Context) { id, err := strconv.ParseUint(c.Param("id"), 10, 64) if err != nil { Fail(c, 400, "invalid id") return } var body struct { FollowStatus string `json:"follow_status" binding:"required"` } if err := c.ShouldBindJSON(&body); err != nil { Fail(c, 400, err.Error()) return } allowed := map[string]bool{"pending": true, "contacted": true, "cooperating": true, "rejected": true} if !allowed[body.FollowStatus] { Fail(c, 400, "invalid follow_status") return } var merchant model.MerchantClean if err := h.store.DB.First(&merchant, id).Error; err != nil { Fail(c, 404, "merchant not found") return } if err := h.store.DB.Model(&merchant).Update("follow_status", body.FollowStatus).Error; err != nil { Fail(c, 500, err.Error()) return } LogAudit(h.store, c, "update", "merchant", fmt.Sprintf("%d", id), gin.H{"follow_status": body.FollowStatus}) OK(c, nil) } // ListNotes handles GET /merchants/clean/:id/notes func (h *MerchantHandler) ListNotes(c *gin.Context) { id, err := strconv.ParseUint(c.Param("id"), 10, 64) if err != nil { Fail(c, 400, "invalid id") return } var notes []model.MerchantNote if err := h.store.DB.Where("merchant_id = ?", id).Order("created_at DESC").Find(¬es).Error; err != nil { Fail(c, 500, err.Error()) return } OK(c, notes) } // AssignMerchant handles PUT /merchants/clean/:id/assign func (h *MerchantHandler) AssignMerchant(c *gin.Context) { id, err := strconv.ParseUint(c.Param("id"), 10, 64) if err != nil { Fail(c, 400, "invalid id") return } var body struct { AssignedTo string `json:"assigned_to"` } if err := c.ShouldBindJSON(&body); err != nil { Fail(c, 400, err.Error()) return } var merchant model.MerchantClean if err := h.store.DB.First(&merchant, id).Error; err != nil { Fail(c, 404, "merchant not found") return } if err := h.store.DB.Model(&merchant).Update("assigned_to", body.AssignedTo).Error; err != nil { Fail(c, 500, err.Error()) return } h.store.DB.First(&merchant, id) LogAudit(h.store, c, "assign", "merchant", fmt.Sprintf("%d", id), gin.H{"assigned_to": body.AssignedTo}) OK(c, merchant) } // BatchAssign handles PUT /merchants/clean/batch-assign func (h *MerchantHandler) BatchAssign(c *gin.Context) { var body struct { IDs []uint `json:"ids" binding:"required"` AssignedTo string `json:"assigned_to"` } if err := c.ShouldBindJSON(&body); err != nil { Fail(c, 400, err.Error()) return } if err := h.store.DB.Model(&model.MerchantClean{}).Where("id IN ?", body.IDs). Update("assigned_to", body.AssignedTo).Error; err != nil { Fail(c, 500, err.Error()) return } OK(c, gin.H{"updated": len(body.IDs)}) } // BatchFollowStatus handles PUT /merchants/clean/batch-follow-status func (h *MerchantHandler) BatchFollowStatus(c *gin.Context) { var body struct { IDs []uint `json:"ids" binding:"required"` FollowStatus string `json:"follow_status" binding:"required"` } if err := c.ShouldBindJSON(&body); err != nil { Fail(c, 400, err.Error()) return } allowed := map[string]bool{"pending": true, "contacted": true, "cooperating": true, "rejected": true} if !allowed[body.FollowStatus] { Fail(c, 400, "invalid follow_status") return } if err := h.store.DB.Model(&model.MerchantClean{}).Where("id IN ?", body.IDs). Update("follow_status", body.FollowStatus).Error; err != nil { Fail(c, 500, err.Error()) return } OK(c, gin.H{"updated": len(body.IDs)}) } // BatchLevel handles PUT /merchants/clean/batch-level func (h *MerchantHandler) BatchLevel(c *gin.Context) { var body struct { IDs []uint `json:"ids" binding:"required"` Level string `json:"level" binding:"required"` } if err := c.ShouldBindJSON(&body); err != nil { Fail(c, 400, err.Error()) return } if err := h.store.DB.Model(&model.MerchantClean{}).Where("id IN ?", body.IDs). Update("level", body.Level).Error; err != nil { Fail(c, 500, err.Error()) return } OK(c, gin.H{"updated": len(body.IDs)}) } // ImportCSV handles POST /merchants/clean/import func (h *MerchantHandler) ImportCSV(c *gin.Context) { file, header, err := c.Request.FormFile("file") if err != nil { Fail(c, 400, "请上传CSV文件") return } defer file.Close() // File size limit (10MB default) maxSize := int64(10 << 20) if header.Size > maxSize { Fail(c, 400, "文件大小不能超过10MB") return } // Validate file extension if !strings.HasSuffix(strings.ToLower(header.Filename), ".csv") { Fail(c, 400, "仅支持CSV格式文件") return } // Read BOM if present bom := make([]byte, 3) n, _ := file.Read(bom) if n < 3 || bom[0] != 0xEF || bom[1] != 0xBB || bom[2] != 0xBF { // Not BOM, seek back file.Seek(0, io.SeekStart) } reader := csv.NewReader(file) headers, err := reader.Read() if err != nil { Fail(c, 400, "无法读取CSV头部") return } // Map column indices colMap := map[string]int{} for i, h := range headers { colMap[strings.TrimSpace(strings.ToLower(h))] = i } // Accept both English and Chinese headers headerAliases := map[string][]string{ "tg_username": {"tg_username", "tg用户名", "username"}, "merchant_name": {"merchant_name", "商户名", "name"}, "website": {"website", "网站"}, "email": {"email", "邮箱"}, "phone": {"phone", "电话"}, "industry_tag": {"industry_tag", "行业", "industry"}, "level": {"level", "等级"}, } getCol := func(field string) int { for _, alias := range headerAliases[field] { if idx, ok := colMap[alias]; ok { return idx } } return -1 } tgIdx := getCol("tg_username") if tgIdx < 0 { Fail(c, 400, "CSV必须包含 tg_username 列") return } nameIdx := getCol("merchant_name") websiteIdx := getCol("website") emailIdx := getCol("email") phoneIdx := getCol("phone") industryIdx := getCol("industry_tag") levelIdx := getCol("level") getField := func(row []string, idx int) string { if idx >= 0 && idx < len(row) { return strings.TrimSpace(row[idx]) } return "" } var imported, skipped, failed int var errors []string rowNum := 1 for { row, err := reader.Read() if err == io.EOF { break } rowNum++ if err != nil { failed++ errors = append(errors, fmt.Sprintf("行 %d: 读取错误", rowNum)) continue } tgUsername := strings.TrimPrefix(strings.TrimSpace(getField(row, tgIdx)), "@") if tgUsername == "" { failed++ errors = append(errors, fmt.Sprintf("行 %d: tg_username 为空", rowNum)) continue } // Check duplicate var count int64 h.store.DB.Model(&model.MerchantClean{}).Where("tg_username = ?", tgUsername).Count(&count) if count > 0 { skipped++ continue } level := getField(row, levelIdx) if level == "" { level = "Cold" } merchant := model.MerchantClean{ TgUsername: tgUsername, TgLink: "https://t.me/" + tgUsername, MerchantName: getField(row, nameIdx), Website: getField(row, websiteIdx), Email: getField(row, emailIdx), Phone: getField(row, phoneIdx), IndustryTag: getField(row, industryIdx), Level: level, Status: "valid", FollowStatus: "pending", SourceCount: 1, } if err := h.store.DB.Create(&merchant).Error; err != nil { failed++ errors = append(errors, fmt.Sprintf("行 %d: %s", rowNum, err.Error())) continue } imported++ } LogAudit(h.store, c, "import", "merchant", "", gin.H{"imported": imported, "skipped": skipped, "failed": failed}) OK(c, gin.H{ "imported": imported, "skipped": skipped, "failed": failed, "errors": errors, }) } // ArchiveMerchants handles POST /merchants/archive func (h *MerchantHandler) ArchiveMerchants(c *gin.Context) { var body struct { MaxDaysInvalid int `json:"max_days_invalid"` // default 90 MaxDaysRejected int `json:"max_days_rejected"` // default 180 } c.ShouldBindJSON(&body) if body.MaxDaysInvalid <= 0 { body.MaxDaysInvalid = 90 } if body.MaxDaysRejected <= 0 { body.MaxDaysRejected = 180 } now := time.Now() invalidCutoff := now.AddDate(0, 0, -body.MaxDaysInvalid) rejectedCutoff := now.AddDate(0, 0, -body.MaxDaysRejected) var merchants []model.MerchantClean h.store.DB.Where( "(status IN ? AND updated_at < ?) OR (follow_status = ? AND updated_at < ?)", []string{"invalid", "bot"}, invalidCutoff, "rejected", rejectedCutoff, ).Find(&merchants) archived := 0 for _, m := range merchants { reason := "status:" + m.Status if m.FollowStatus == "rejected" { reason = "follow_status:rejected" } arch := model.MerchantArchived{ OriginalID: m.ID, TgUsername: m.TgUsername, TgLink: m.TgLink, MerchantName: m.MerchantName, Website: m.Website, Email: m.Email, Phone: m.Phone, SourceCount: m.SourceCount, AllSources: m.AllSources, IndustryTag: m.IndustryTag, Level: m.Level, Status: m.Status, FollowStatus: m.FollowStatus, AssignedTo: m.AssignedTo, Remark: m.Remark, IsAlive: m.IsAlive, ArchiveReason: reason, ArchivedAt: now, CreatedAt: m.CreatedAt, } if err := h.store.DB.Create(&arch).Error; err == nil { h.store.DB.Delete(&m) archived++ } } LogAudit(h.store, c, "archive", "merchant", fmt.Sprintf("batch:%d", archived), gin.H{"archived": archived}) OK(c, gin.H{"archived": archived, "candidates": len(merchants)}) } // ListArchived handles GET /merchants/archived func (h *MerchantHandler) ListArchived(c *gin.Context) { page, pageSize, offset := parsePage(c) query := h.store.DB.Model(&model.MerchantArchived{}) if search := c.Query("search"); search != "" { like := "%" + search + "%" query = query.Where("tg_username LIKE ? OR merchant_name LIKE ?", like, like) } var total int64 query.Count(&total) var items []model.MerchantArchived if err := query.Order("archived_at DESC").Limit(pageSize).Offset(offset).Find(&items).Error; err != nil { Fail(c, 500, err.Error()) return } PageOK(c, items, total, page, pageSize) } // RestoreArchived handles POST /merchants/archived/:id/restore func (h *MerchantHandler) RestoreArchived(c *gin.Context) { id, err := strconv.ParseUint(c.Param("id"), 10, 64) if err != nil { Fail(c, 400, "invalid id") return } var arch model.MerchantArchived if err := h.store.DB.First(&arch, id).Error; err != nil { Fail(c, 404, "not found") return } merchant := model.MerchantClean{ TgUsername: arch.TgUsername, TgLink: arch.TgLink, MerchantName: arch.MerchantName, Website: arch.Website, Email: arch.Email, Phone: arch.Phone, SourceCount: arch.SourceCount, AllSources: arch.AllSources, IndustryTag: arch.IndustryTag, Level: arch.Level, Status: "valid", FollowStatus: "pending", AssignedTo: arch.AssignedTo, Remark: arch.Remark, IsAlive: arch.IsAlive, } if err := h.store.DB.Create(&merchant).Error; err != nil { Fail(c, 500, err.Error()) return } h.store.DB.Delete(&arch) LogAudit(h.store, c, "restore", "merchant", fmt.Sprintf("%d", merchant.ID), gin.H{"from_archive": id}) OK(c, merchant) } // RecheckMerchant handles POST /merchants/clean/:id/recheck — re-checks t.me status func (h *MerchantHandler) RecheckMerchant(c *gin.Context) { id, err := strconv.ParseUint(c.Param("id"), 10, 64) if err != nil { Fail(c, 400, "invalid id") return } var merchant model.MerchantClean if err := h.store.DB.First(&merchant, id).Error; err != nil { Fail(c, 404, "merchant not found") return } if merchant.TgUsername == "" { Fail(c, 400, "商户无TG用户名") return } // Mark as checking, update last_checked_at now := time.Now() h.store.DB.Model(&merchant).Updates(map[string]interface{}{ "last_checked_at": now, }) // Return immediately — actual t.me check would need TG client // For now we update the timestamp and let the next clean task verify LogAudit(h.store, c, "recheck", "merchant", fmt.Sprintf("%d", id), gin.H{"tg_username": merchant.TgUsername}) OK(c, gin.H{"message": "已标记为待重新检查", "merchant_id": id}) } // BatchRecheck handles POST /merchants/clean/batch-recheck — batch re-check func (h *MerchantHandler) BatchRecheck(c *gin.Context) { var body struct { IDs []uint `json:"ids" binding:"required"` } if err := c.ShouldBindJSON(&body); err != nil { Fail(c, 400, err.Error()) return } now := time.Now() h.store.DB.Model(&model.MerchantClean{}).Where("id IN ?", body.IDs). Updates(map[string]interface{}{"last_checked_at": now}) LogAudit(h.store, c, "recheck", "merchant", fmt.Sprintf("batch:%d", len(body.IDs)), nil) OK(c, gin.H{"updated": len(body.IDs)}) } // MergeMerchants handles POST /merchants/clean/merge — merges secondary into primary func (h *MerchantHandler) MergeMerchants(c *gin.Context) { var body struct { PrimaryID uint `json:"primary_id" binding:"required"` SecondaryID uint `json:"secondary_id" binding:"required"` } if err := c.ShouldBindJSON(&body); err != nil { Fail(c, 400, err.Error()) return } if body.PrimaryID == body.SecondaryID { Fail(c, 400, "不能合并同一个商户") return } var primary, secondary model.MerchantClean if err := h.store.DB.First(&primary, body.PrimaryID).Error; err != nil { Fail(c, 404, "主商户不存在") return } if err := h.store.DB.First(&secondary, body.SecondaryID).Error; err != nil { Fail(c, 404, "副商户不存在") return } // Fill empty fields from secondary if primary.MerchantName == "" && secondary.MerchantName != "" { primary.MerchantName = secondary.MerchantName } if primary.Website == "" && secondary.Website != "" { primary.Website = secondary.Website } if primary.Email == "" && secondary.Email != "" { primary.Email = secondary.Email } if primary.Phone == "" && secondary.Phone != "" { primary.Phone = secondary.Phone } if primary.IndustryTag == "" && secondary.IndustryTag != "" { primary.IndustryTag = secondary.IndustryTag } // Merge sources var primarySources, secondarySources []map[string]string json.Unmarshal(primary.AllSources, &primarySources) json.Unmarshal(secondary.AllSources, &secondarySources) allSources := append(primarySources, secondarySources...) sourcesJSON, _ := json.Marshal(allSources) primary.AllSources = sourcesJSON primary.SourceCount = len(allSources) // Use the better level levelRank := map[string]int{"Hot": 3, "Warm": 2, "Cold": 1} if levelRank[secondary.Level] > levelRank[primary.Level] { primary.Level = secondary.Level } // Save primary if err := h.store.DB.Save(&primary).Error; err != nil { Fail(c, 500, err.Error()) return } // Transfer notes from secondary to primary h.store.DB.Model(&model.MerchantNote{}). Where("merchant_id = ?", secondary.ID). Update("merchant_id", primary.ID) // Delete secondary h.store.DB.Delete(&secondary) LogAudit(h.store, c, "merge", "merchant", fmt.Sprintf("%d←%d", primary.ID, secondary.ID), gin.H{"primary": primary.ID, "secondary": secondary.ID}) OK(c, primary) } // ListIndustryTags handles GET /merchants/clean/industry-tags — returns distinct industry tags func (h *MerchantHandler) ListIndustryTags(c *gin.Context) { var tags []string h.store.DB.Model(&model.MerchantClean{}). Where("industry_tag != ''"). Distinct("industry_tag"). Pluck("industry_tag", &tags) OK(c, tags) } // ListUsers handles GET /merchants/clean/users — returns operator/admin usernames for assignment dropdown func (h *MerchantHandler) ListUsers(c *gin.Context) { var users []struct { Username string `json:"username"` Nickname string `json:"nickname"` } h.store.DB.Model(&model.User{}). Where("enabled = ? AND role IN ?", true, []string{"admin", "operator"}). Select("username, nickname"). Find(&users) OK(c, users) } // AddNote handles POST /merchants/clean/:id/notes func (h *MerchantHandler) AddNote(c *gin.Context) { id, err := strconv.ParseUint(c.Param("id"), 10, 64) if err != nil { Fail(c, 400, "invalid id") return } var body struct { Content string `json:"content" binding:"required"` } if err := c.ShouldBindJSON(&body); err != nil { Fail(c, 400, err.Error()) return } // Look up the merchant to get tg_username var merchant model.MerchantClean if err := h.store.DB.First(&merchant, id).Error; err != nil { Fail(c, 404, "merchant not found") return } note := model.MerchantNote{ MerchantID: uint(id), TgUsername: merchant.TgUsername, Content: body.Content, CreatedBy: c.GetString("username"), } if err := h.store.DB.Create(¬e).Error; err != nil { Fail(c, 500, err.Error()) return } OK(c, note) }