| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005 |
- 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)
- }
|