merchant.go 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005
  1. package handler
  2. import (
  3. "encoding/csv"
  4. "encoding/json"
  5. "fmt"
  6. "io"
  7. "net/http"
  8. "strconv"
  9. "strings"
  10. "sync"
  11. "time"
  12. "spider/internal/model"
  13. "spider/internal/store"
  14. "github.com/gin-gonic/gin"
  15. )
  16. // MerchantHandler handles merchant queries.
  17. type MerchantHandler struct {
  18. store *store.Store
  19. statsCache *statsCache
  20. }
  21. // statsCache provides a simple time-based cache for stats.
  22. type statsCache struct {
  23. mu sync.Mutex
  24. data []byte
  25. expiresAt time.Time
  26. }
  27. func (sc *statsCache) get() (gin.H, bool) {
  28. if sc == nil {
  29. return nil, false
  30. }
  31. sc.mu.Lock()
  32. defer sc.mu.Unlock()
  33. if time.Now().Before(sc.expiresAt) && sc.data != nil {
  34. var result gin.H
  35. json.Unmarshal(sc.data, &result)
  36. return result, true
  37. }
  38. return nil, false
  39. }
  40. func (sc *statsCache) set(data gin.H) {
  41. if sc == nil {
  42. return
  43. }
  44. sc.mu.Lock()
  45. defer sc.mu.Unlock()
  46. sc.data, _ = json.Marshal(data)
  47. sc.expiresAt = time.Now().Add(30 * time.Second)
  48. }
  49. // Stats returns aggregate statistics for merchants (cached 30s).
  50. func (h *MerchantHandler) Stats(c *gin.Context) {
  51. if h.statsCache == nil {
  52. h.statsCache = &statsCache{}
  53. }
  54. if cached, ok := h.statsCache.get(); ok {
  55. OK(c, cached)
  56. return
  57. }
  58. var rawTotal int64
  59. h.store.DB.Model(&model.MerchantRaw{}).Count(&rawTotal)
  60. var cleanTotal int64
  61. h.store.DB.Model(&model.MerchantClean{}).Count(&cleanTotal)
  62. // Count by status
  63. var statusRows []struct {
  64. Status string
  65. Cnt int64
  66. }
  67. h.store.DB.Model(&model.MerchantClean{}).
  68. Select("status, count(*) as cnt").
  69. Group("status").
  70. Scan(&statusRows)
  71. byStatus := map[string]int64{}
  72. for _, r := range statusRows {
  73. byStatus[r.Status] = r.Cnt
  74. }
  75. // Count by level
  76. var levelRows []struct {
  77. Level string
  78. Cnt int64
  79. }
  80. h.store.DB.Model(&model.MerchantClean{}).
  81. Select("level, count(*) as cnt").
  82. Group("level").
  83. Scan(&levelRows)
  84. byLevel := map[string]int64{}
  85. for _, r := range levelRows {
  86. byLevel[r.Level] = r.Cnt
  87. }
  88. // Count by source_type
  89. var sourceRows []struct {
  90. SourceType string
  91. Cnt int64
  92. }
  93. h.store.DB.Model(&model.MerchantRaw{}).
  94. Select("source_type, count(*) as cnt").
  95. Group("source_type").
  96. Scan(&sourceRows)
  97. bySource := map[string]int64{}
  98. for _, r := range sourceRows {
  99. bySource[r.SourceType] = r.Cnt
  100. }
  101. result := gin.H{
  102. "raw_total": rawTotal,
  103. "clean_total": cleanTotal,
  104. "by_status": byStatus,
  105. "by_level": byLevel,
  106. "by_source": bySource,
  107. }
  108. h.statsCache.set(result)
  109. OK(c, result)
  110. }
  111. // ListRaw returns raw merchants with filters and pagination.
  112. func (h *MerchantHandler) ListRaw(c *gin.Context) {
  113. page, pageSize, offset := parsePage(c)
  114. query := h.store.DB.Model(&model.MerchantRaw{})
  115. if status := c.Query("status"); status != "" {
  116. query = query.Where("status = ?", status)
  117. }
  118. if sourceType := c.Query("source_type"); sourceType != "" {
  119. query = query.Where("source_type = ?", sourceType)
  120. }
  121. if search := c.Query("search"); search != "" {
  122. like := "%" + search + "%"
  123. query = query.Where("tg_username LIKE ? OR merchant_name LIKE ?", like, like)
  124. }
  125. var total int64
  126. query.Count(&total)
  127. var items []model.MerchantRaw
  128. if err := query.Order("created_at DESC").Limit(pageSize).Offset(offset).Find(&items).Error; err != nil {
  129. Fail(c, 500, err.Error())
  130. return
  131. }
  132. PageOK(c, items, total, page, pageSize)
  133. }
  134. // ListClean returns clean merchants with filters and pagination.
  135. func (h *MerchantHandler) ListClean(c *gin.Context) {
  136. page, pageSize, offset := parsePage(c)
  137. query := h.store.DB.Model(&model.MerchantClean{})
  138. if status := c.Query("status"); status != "" {
  139. query = query.Where("status = ?", status)
  140. }
  141. if level := c.Query("level"); level != "" {
  142. query = query.Where("level = ?", level)
  143. }
  144. if industry := c.Query("industry_tag"); industry != "" {
  145. query = query.Where("industry_tag = ?", industry)
  146. }
  147. if followStatus := c.Query("follow_status"); followStatus != "" {
  148. query = query.Where("follow_status = ?", followStatus)
  149. }
  150. if assignedTo := c.Query("assigned_to"); assignedTo != "" {
  151. if assignedTo == "__unassigned__" {
  152. query = query.Where("assigned_to = '' OR assigned_to IS NULL")
  153. } else {
  154. query = query.Where("assigned_to = ?", assignedTo)
  155. }
  156. }
  157. if search := c.Query("search"); search != "" {
  158. like := "%" + search + "%"
  159. query = query.Where("tg_username LIKE ? OR merchant_name LIKE ?", like, like)
  160. }
  161. if hasContact := c.Query("has_contact"); hasContact == "1" {
  162. query = query.Where("website != '' OR email != '' OR phone != ''")
  163. }
  164. sortField := c.DefaultQuery("sort", "created_at")
  165. allowedSort := map[string]bool{
  166. "created_at": true,
  167. "updated_at": true,
  168. "source_count": true,
  169. "level": true,
  170. }
  171. if !allowedSort[sortField] {
  172. sortField = "created_at"
  173. }
  174. order := c.DefaultQuery("order", "desc")
  175. if order != "asc" && order != "desc" {
  176. order = "desc"
  177. }
  178. var total int64
  179. query.Count(&total)
  180. var items []model.MerchantClean
  181. if err := query.Order(sortField + " " + order).Limit(pageSize).Offset(offset).Find(&items).Error; err != nil {
  182. Fail(c, 500, err.Error())
  183. return
  184. }
  185. PageOK(c, items, total, page, pageSize)
  186. }
  187. // ExportCSV exports clean merchants as CSV.
  188. func (h *MerchantHandler) ExportCSV(c *gin.Context) {
  189. query := h.store.DB.Model(&model.MerchantClean{})
  190. if status := c.Query("status"); status != "" {
  191. query = query.Where("status = ?", status)
  192. } else {
  193. query = query.Where("status = ?", "valid")
  194. }
  195. if level := c.Query("level"); level != "" {
  196. query = query.Where("level = ?", level)
  197. }
  198. if followStatus := c.Query("follow_status"); followStatus != "" {
  199. query = query.Where("follow_status = ?", followStatus)
  200. }
  201. if assignedTo := c.Query("assigned_to"); assignedTo != "" {
  202. if assignedTo == "__unassigned__" {
  203. query = query.Where("assigned_to = '' OR assigned_to IS NULL")
  204. } else {
  205. query = query.Where("assigned_to = ?", assignedTo)
  206. }
  207. }
  208. if industryTag := c.Query("industry_tag"); industryTag != "" {
  209. query = query.Where("industry_tag = ?", industryTag)
  210. }
  211. if search := c.Query("search"); search != "" {
  212. like := "%" + search + "%"
  213. query = query.Where("tg_username LIKE ? OR merchant_name LIKE ?", like, like)
  214. }
  215. if hasContact := c.Query("has_contact"); hasContact == "1" {
  216. query = query.Where("website != '' OR email != '' OR phone != ''")
  217. }
  218. // Cap export at 50000 rows to prevent OOM
  219. var merchants []model.MerchantClean
  220. query.Order("level ASC, created_at DESC").Limit(50000).Find(&merchants)
  221. c.Header("Content-Type", "text/csv; charset=utf-8")
  222. c.Header("Content-Disposition", "attachment; filename=merchants.csv")
  223. // Write BOM for Excel compatibility
  224. c.Writer.Write([]byte{0xEF, 0xBB, 0xBF})
  225. w := csv.NewWriter(c.Writer)
  226. w.Write([]string{"商户名", "TG用户名", "TG链接", "网站", "邮箱", "电话", "行业", "等级", "跟进状态", "负责人", "来源数", "备注"})
  227. for _, m := range merchants {
  228. w.Write([]string{
  229. m.MerchantName,
  230. m.TgUsername,
  231. m.TgLink,
  232. m.Website,
  233. m.Email,
  234. m.Phone,
  235. m.IndustryTag,
  236. m.Level,
  237. m.FollowStatus,
  238. m.AssignedTo,
  239. fmt.Sprintf("%d", m.SourceCount),
  240. m.Remark,
  241. })
  242. }
  243. w.Flush()
  244. }
  245. // BatchDeleteRaw handles DELETE /merchants/raw/batch
  246. func (h *MerchantHandler) BatchDeleteRaw(c *gin.Context) {
  247. var body struct {
  248. IDs []uint `json:"ids" binding:"required"`
  249. }
  250. if err := c.ShouldBindJSON(&body); err != nil {
  251. Fail(c, 400, err.Error())
  252. return
  253. }
  254. if err := h.store.DB.Where("id IN ?", body.IDs).Delete(&model.MerchantRaw{}).Error; err != nil {
  255. Fail(c, 500, err.Error())
  256. return
  257. }
  258. OK(c, gin.H{"deleted": len(body.IDs)})
  259. }
  260. // BatchDeleteClean handles DELETE /merchants/clean/batch
  261. func (h *MerchantHandler) BatchDeleteClean(c *gin.Context) {
  262. var body struct {
  263. IDs []uint `json:"ids" binding:"required"`
  264. }
  265. if err := c.ShouldBindJSON(&body); err != nil {
  266. Fail(c, 400, err.Error())
  267. return
  268. }
  269. if err := h.store.DB.Where("id IN ?", body.IDs).Delete(&model.MerchantClean{}).Error; err != nil {
  270. Fail(c, 500, err.Error())
  271. return
  272. }
  273. LogAudit(h.store, c, "delete", "merchant", fmt.Sprintf("batch:%d", len(body.IDs)), gin.H{"ids": body.IDs})
  274. OK(c, gin.H{"deleted": len(body.IDs)})
  275. }
  276. // GetByID fetches a merchant by ID.
  277. func (h *MerchantHandler) GetByID(c *gin.Context) {
  278. id, err := strconv.ParseUint(c.Param("id"), 10, 64)
  279. if err != nil {
  280. Fail(c, http.StatusBadRequest, "invalid id")
  281. return
  282. }
  283. var clean model.MerchantClean
  284. if err := h.store.DB.First(&clean, id).Error; err == nil {
  285. OK(c, gin.H{"source": "clean", "data": clean})
  286. return
  287. }
  288. var raw model.MerchantRaw
  289. if err := h.store.DB.First(&raw, id).Error; err == nil {
  290. OK(c, gin.H{"source": "raw", "data": raw})
  291. return
  292. }
  293. Fail(c, 404, "merchant not found")
  294. }
  295. // UpdateClean handles PUT /merchants/clean/:id
  296. func (h *MerchantHandler) UpdateClean(c *gin.Context) {
  297. id, err := strconv.ParseUint(c.Param("id"), 10, 64)
  298. if err != nil {
  299. Fail(c, 400, "invalid id")
  300. return
  301. }
  302. var body struct {
  303. MerchantName *string `json:"merchant_name"`
  304. IndustryTag *string `json:"industry_tag"`
  305. Website *string `json:"website"`
  306. Email *string `json:"email"`
  307. Phone *string `json:"phone"`
  308. Remark *string `json:"remark"`
  309. AssignedTo *string `json:"assigned_to"`
  310. }
  311. if err := c.ShouldBindJSON(&body); err != nil {
  312. Fail(c, 400, err.Error())
  313. return
  314. }
  315. updates := map[string]interface{}{}
  316. if body.MerchantName != nil {
  317. updates["merchant_name"] = *body.MerchantName
  318. }
  319. if body.IndustryTag != nil {
  320. updates["industry_tag"] = *body.IndustryTag
  321. }
  322. if body.Website != nil {
  323. updates["website"] = *body.Website
  324. }
  325. if body.Email != nil {
  326. updates["email"] = *body.Email
  327. }
  328. if body.Phone != nil {
  329. updates["phone"] = *body.Phone
  330. }
  331. if body.Remark != nil {
  332. updates["remark"] = *body.Remark
  333. }
  334. if body.AssignedTo != nil {
  335. updates["assigned_to"] = *body.AssignedTo
  336. }
  337. if len(updates) == 0 {
  338. Fail(c, 400, "no fields to update")
  339. return
  340. }
  341. var merchant model.MerchantClean
  342. if err := h.store.DB.First(&merchant, id).Error; err != nil {
  343. Fail(c, 404, "merchant not found")
  344. return
  345. }
  346. if err := h.store.DB.Model(&merchant).Updates(updates).Error; err != nil {
  347. Fail(c, 500, err.Error())
  348. return
  349. }
  350. h.store.DB.First(&merchant, id)
  351. LogAudit(h.store, c, "update", "merchant", fmt.Sprintf("%d", id), updates)
  352. OK(c, merchant)
  353. }
  354. // UpdateFollowStatus handles PUT /merchants/clean/:id/follow-status
  355. func (h *MerchantHandler) UpdateFollowStatus(c *gin.Context) {
  356. id, err := strconv.ParseUint(c.Param("id"), 10, 64)
  357. if err != nil {
  358. Fail(c, 400, "invalid id")
  359. return
  360. }
  361. var body struct {
  362. FollowStatus string `json:"follow_status" binding:"required"`
  363. }
  364. if err := c.ShouldBindJSON(&body); err != nil {
  365. Fail(c, 400, err.Error())
  366. return
  367. }
  368. allowed := map[string]bool{"pending": true, "contacted": true, "cooperating": true, "rejected": true}
  369. if !allowed[body.FollowStatus] {
  370. Fail(c, 400, "invalid follow_status")
  371. return
  372. }
  373. var merchant model.MerchantClean
  374. if err := h.store.DB.First(&merchant, id).Error; err != nil {
  375. Fail(c, 404, "merchant not found")
  376. return
  377. }
  378. if err := h.store.DB.Model(&merchant).Update("follow_status", body.FollowStatus).Error; err != nil {
  379. Fail(c, 500, err.Error())
  380. return
  381. }
  382. LogAudit(h.store, c, "update", "merchant", fmt.Sprintf("%d", id), gin.H{"follow_status": body.FollowStatus})
  383. OK(c, nil)
  384. }
  385. // ListNotes handles GET /merchants/clean/:id/notes
  386. func (h *MerchantHandler) ListNotes(c *gin.Context) {
  387. id, err := strconv.ParseUint(c.Param("id"), 10, 64)
  388. if err != nil {
  389. Fail(c, 400, "invalid id")
  390. return
  391. }
  392. var notes []model.MerchantNote
  393. if err := h.store.DB.Where("merchant_id = ?", id).Order("created_at DESC").Find(&notes).Error; err != nil {
  394. Fail(c, 500, err.Error())
  395. return
  396. }
  397. OK(c, notes)
  398. }
  399. // AssignMerchant handles PUT /merchants/clean/:id/assign
  400. func (h *MerchantHandler) AssignMerchant(c *gin.Context) {
  401. id, err := strconv.ParseUint(c.Param("id"), 10, 64)
  402. if err != nil {
  403. Fail(c, 400, "invalid id")
  404. return
  405. }
  406. var body struct {
  407. AssignedTo string `json:"assigned_to"`
  408. }
  409. if err := c.ShouldBindJSON(&body); err != nil {
  410. Fail(c, 400, err.Error())
  411. return
  412. }
  413. var merchant model.MerchantClean
  414. if err := h.store.DB.First(&merchant, id).Error; err != nil {
  415. Fail(c, 404, "merchant not found")
  416. return
  417. }
  418. if err := h.store.DB.Model(&merchant).Update("assigned_to", body.AssignedTo).Error; err != nil {
  419. Fail(c, 500, err.Error())
  420. return
  421. }
  422. h.store.DB.First(&merchant, id)
  423. LogAudit(h.store, c, "assign", "merchant", fmt.Sprintf("%d", id), gin.H{"assigned_to": body.AssignedTo})
  424. OK(c, merchant)
  425. }
  426. // BatchAssign handles PUT /merchants/clean/batch-assign
  427. func (h *MerchantHandler) BatchAssign(c *gin.Context) {
  428. var body struct {
  429. IDs []uint `json:"ids" binding:"required"`
  430. AssignedTo string `json:"assigned_to"`
  431. }
  432. if err := c.ShouldBindJSON(&body); err != nil {
  433. Fail(c, 400, err.Error())
  434. return
  435. }
  436. if err := h.store.DB.Model(&model.MerchantClean{}).Where("id IN ?", body.IDs).
  437. Update("assigned_to", body.AssignedTo).Error; err != nil {
  438. Fail(c, 500, err.Error())
  439. return
  440. }
  441. OK(c, gin.H{"updated": len(body.IDs)})
  442. }
  443. // BatchFollowStatus handles PUT /merchants/clean/batch-follow-status
  444. func (h *MerchantHandler) BatchFollowStatus(c *gin.Context) {
  445. var body struct {
  446. IDs []uint `json:"ids" binding:"required"`
  447. FollowStatus string `json:"follow_status" binding:"required"`
  448. }
  449. if err := c.ShouldBindJSON(&body); err != nil {
  450. Fail(c, 400, err.Error())
  451. return
  452. }
  453. allowed := map[string]bool{"pending": true, "contacted": true, "cooperating": true, "rejected": true}
  454. if !allowed[body.FollowStatus] {
  455. Fail(c, 400, "invalid follow_status")
  456. return
  457. }
  458. if err := h.store.DB.Model(&model.MerchantClean{}).Where("id IN ?", body.IDs).
  459. Update("follow_status", body.FollowStatus).Error; err != nil {
  460. Fail(c, 500, err.Error())
  461. return
  462. }
  463. OK(c, gin.H{"updated": len(body.IDs)})
  464. }
  465. // BatchLevel handles PUT /merchants/clean/batch-level
  466. func (h *MerchantHandler) BatchLevel(c *gin.Context) {
  467. var body struct {
  468. IDs []uint `json:"ids" binding:"required"`
  469. Level string `json:"level" binding:"required"`
  470. }
  471. if err := c.ShouldBindJSON(&body); err != nil {
  472. Fail(c, 400, err.Error())
  473. return
  474. }
  475. if err := h.store.DB.Model(&model.MerchantClean{}).Where("id IN ?", body.IDs).
  476. Update("level", body.Level).Error; err != nil {
  477. Fail(c, 500, err.Error())
  478. return
  479. }
  480. OK(c, gin.H{"updated": len(body.IDs)})
  481. }
  482. // ImportCSV handles POST /merchants/clean/import
  483. func (h *MerchantHandler) ImportCSV(c *gin.Context) {
  484. file, header, err := c.Request.FormFile("file")
  485. if err != nil {
  486. Fail(c, 400, "请上传CSV文件")
  487. return
  488. }
  489. defer file.Close()
  490. // File size limit (10MB default)
  491. maxSize := int64(10 << 20)
  492. if header.Size > maxSize {
  493. Fail(c, 400, "文件大小不能超过10MB")
  494. return
  495. }
  496. // Validate file extension
  497. if !strings.HasSuffix(strings.ToLower(header.Filename), ".csv") {
  498. Fail(c, 400, "仅支持CSV格式文件")
  499. return
  500. }
  501. // Read BOM if present
  502. bom := make([]byte, 3)
  503. n, _ := file.Read(bom)
  504. if n < 3 || bom[0] != 0xEF || bom[1] != 0xBB || bom[2] != 0xBF {
  505. // Not BOM, seek back
  506. file.Seek(0, io.SeekStart)
  507. }
  508. reader := csv.NewReader(file)
  509. headers, err := reader.Read()
  510. if err != nil {
  511. Fail(c, 400, "无法读取CSV头部")
  512. return
  513. }
  514. // Map column indices
  515. colMap := map[string]int{}
  516. for i, h := range headers {
  517. colMap[strings.TrimSpace(strings.ToLower(h))] = i
  518. }
  519. // Accept both English and Chinese headers
  520. headerAliases := map[string][]string{
  521. "tg_username": {"tg_username", "tg用户名", "username"},
  522. "merchant_name": {"merchant_name", "商户名", "name"},
  523. "website": {"website", "网站"},
  524. "email": {"email", "邮箱"},
  525. "phone": {"phone", "电话"},
  526. "industry_tag": {"industry_tag", "行业", "industry"},
  527. "level": {"level", "等级"},
  528. }
  529. getCol := func(field string) int {
  530. for _, alias := range headerAliases[field] {
  531. if idx, ok := colMap[alias]; ok {
  532. return idx
  533. }
  534. }
  535. return -1
  536. }
  537. tgIdx := getCol("tg_username")
  538. if tgIdx < 0 {
  539. Fail(c, 400, "CSV必须包含 tg_username 列")
  540. return
  541. }
  542. nameIdx := getCol("merchant_name")
  543. websiteIdx := getCol("website")
  544. emailIdx := getCol("email")
  545. phoneIdx := getCol("phone")
  546. industryIdx := getCol("industry_tag")
  547. levelIdx := getCol("level")
  548. getField := func(row []string, idx int) string {
  549. if idx >= 0 && idx < len(row) {
  550. return strings.TrimSpace(row[idx])
  551. }
  552. return ""
  553. }
  554. var imported, skipped, failed int
  555. var errors []string
  556. rowNum := 1
  557. for {
  558. row, err := reader.Read()
  559. if err == io.EOF {
  560. break
  561. }
  562. rowNum++
  563. if err != nil {
  564. failed++
  565. errors = append(errors, fmt.Sprintf("行 %d: 读取错误", rowNum))
  566. continue
  567. }
  568. tgUsername := strings.TrimPrefix(strings.TrimSpace(getField(row, tgIdx)), "@")
  569. if tgUsername == "" {
  570. failed++
  571. errors = append(errors, fmt.Sprintf("行 %d: tg_username 为空", rowNum))
  572. continue
  573. }
  574. // Check duplicate
  575. var count int64
  576. h.store.DB.Model(&model.MerchantClean{}).Where("tg_username = ?", tgUsername).Count(&count)
  577. if count > 0 {
  578. skipped++
  579. continue
  580. }
  581. level := getField(row, levelIdx)
  582. if level == "" {
  583. level = "Cold"
  584. }
  585. merchant := model.MerchantClean{
  586. TgUsername: tgUsername,
  587. TgLink: "https://t.me/" + tgUsername,
  588. MerchantName: getField(row, nameIdx),
  589. Website: getField(row, websiteIdx),
  590. Email: getField(row, emailIdx),
  591. Phone: getField(row, phoneIdx),
  592. IndustryTag: getField(row, industryIdx),
  593. Level: level,
  594. Status: "valid",
  595. FollowStatus: "pending",
  596. SourceCount: 1,
  597. }
  598. if err := h.store.DB.Create(&merchant).Error; err != nil {
  599. failed++
  600. errors = append(errors, fmt.Sprintf("行 %d: %s", rowNum, err.Error()))
  601. continue
  602. }
  603. imported++
  604. }
  605. LogAudit(h.store, c, "import", "merchant", "", gin.H{"imported": imported, "skipped": skipped, "failed": failed})
  606. OK(c, gin.H{
  607. "imported": imported,
  608. "skipped": skipped,
  609. "failed": failed,
  610. "errors": errors,
  611. })
  612. }
  613. // ArchiveMerchants handles POST /merchants/archive
  614. func (h *MerchantHandler) ArchiveMerchants(c *gin.Context) {
  615. var body struct {
  616. MaxDaysInvalid int `json:"max_days_invalid"` // default 90
  617. MaxDaysRejected int `json:"max_days_rejected"` // default 180
  618. }
  619. c.ShouldBindJSON(&body)
  620. if body.MaxDaysInvalid <= 0 {
  621. body.MaxDaysInvalid = 90
  622. }
  623. if body.MaxDaysRejected <= 0 {
  624. body.MaxDaysRejected = 180
  625. }
  626. now := time.Now()
  627. invalidCutoff := now.AddDate(0, 0, -body.MaxDaysInvalid)
  628. rejectedCutoff := now.AddDate(0, 0, -body.MaxDaysRejected)
  629. var merchants []model.MerchantClean
  630. h.store.DB.Where(
  631. "(status IN ? AND updated_at < ?) OR (follow_status = ? AND updated_at < ?)",
  632. []string{"invalid", "bot"}, invalidCutoff,
  633. "rejected", rejectedCutoff,
  634. ).Find(&merchants)
  635. archived := 0
  636. for _, m := range merchants {
  637. reason := "status:" + m.Status
  638. if m.FollowStatus == "rejected" {
  639. reason = "follow_status:rejected"
  640. }
  641. arch := model.MerchantArchived{
  642. OriginalID: m.ID,
  643. TgUsername: m.TgUsername,
  644. TgLink: m.TgLink,
  645. MerchantName: m.MerchantName,
  646. Website: m.Website,
  647. Email: m.Email,
  648. Phone: m.Phone,
  649. SourceCount: m.SourceCount,
  650. AllSources: m.AllSources,
  651. IndustryTag: m.IndustryTag,
  652. Level: m.Level,
  653. Status: m.Status,
  654. FollowStatus: m.FollowStatus,
  655. AssignedTo: m.AssignedTo,
  656. Remark: m.Remark,
  657. IsAlive: m.IsAlive,
  658. ArchiveReason: reason,
  659. ArchivedAt: now,
  660. CreatedAt: m.CreatedAt,
  661. }
  662. if err := h.store.DB.Create(&arch).Error; err == nil {
  663. h.store.DB.Delete(&m)
  664. archived++
  665. }
  666. }
  667. LogAudit(h.store, c, "archive", "merchant", fmt.Sprintf("batch:%d", archived), gin.H{"archived": archived})
  668. OK(c, gin.H{"archived": archived, "candidates": len(merchants)})
  669. }
  670. // ListArchived handles GET /merchants/archived
  671. func (h *MerchantHandler) ListArchived(c *gin.Context) {
  672. page, pageSize, offset := parsePage(c)
  673. query := h.store.DB.Model(&model.MerchantArchived{})
  674. if search := c.Query("search"); search != "" {
  675. like := "%" + search + "%"
  676. query = query.Where("tg_username LIKE ? OR merchant_name LIKE ?", like, like)
  677. }
  678. var total int64
  679. query.Count(&total)
  680. var items []model.MerchantArchived
  681. if err := query.Order("archived_at DESC").Limit(pageSize).Offset(offset).Find(&items).Error; err != nil {
  682. Fail(c, 500, err.Error())
  683. return
  684. }
  685. PageOK(c, items, total, page, pageSize)
  686. }
  687. // RestoreArchived handles POST /merchants/archived/:id/restore
  688. func (h *MerchantHandler) RestoreArchived(c *gin.Context) {
  689. id, err := strconv.ParseUint(c.Param("id"), 10, 64)
  690. if err != nil {
  691. Fail(c, 400, "invalid id")
  692. return
  693. }
  694. var arch model.MerchantArchived
  695. if err := h.store.DB.First(&arch, id).Error; err != nil {
  696. Fail(c, 404, "not found")
  697. return
  698. }
  699. merchant := model.MerchantClean{
  700. TgUsername: arch.TgUsername,
  701. TgLink: arch.TgLink,
  702. MerchantName: arch.MerchantName,
  703. Website: arch.Website,
  704. Email: arch.Email,
  705. Phone: arch.Phone,
  706. SourceCount: arch.SourceCount,
  707. AllSources: arch.AllSources,
  708. IndustryTag: arch.IndustryTag,
  709. Level: arch.Level,
  710. Status: "valid",
  711. FollowStatus: "pending",
  712. AssignedTo: arch.AssignedTo,
  713. Remark: arch.Remark,
  714. IsAlive: arch.IsAlive,
  715. }
  716. if err := h.store.DB.Create(&merchant).Error; err != nil {
  717. Fail(c, 500, err.Error())
  718. return
  719. }
  720. h.store.DB.Delete(&arch)
  721. LogAudit(h.store, c, "restore", "merchant", fmt.Sprintf("%d", merchant.ID), gin.H{"from_archive": id})
  722. OK(c, merchant)
  723. }
  724. // RecheckMerchant handles POST /merchants/clean/:id/recheck — re-checks t.me status
  725. func (h *MerchantHandler) RecheckMerchant(c *gin.Context) {
  726. id, err := strconv.ParseUint(c.Param("id"), 10, 64)
  727. if err != nil {
  728. Fail(c, 400, "invalid id")
  729. return
  730. }
  731. var merchant model.MerchantClean
  732. if err := h.store.DB.First(&merchant, id).Error; err != nil {
  733. Fail(c, 404, "merchant not found")
  734. return
  735. }
  736. if merchant.TgUsername == "" {
  737. Fail(c, 400, "商户无TG用户名")
  738. return
  739. }
  740. // Mark as checking, update last_checked_at
  741. now := time.Now()
  742. h.store.DB.Model(&merchant).Updates(map[string]interface{}{
  743. "last_checked_at": now,
  744. })
  745. // Return immediately — actual t.me check would need TG client
  746. // For now we update the timestamp and let the next clean task verify
  747. LogAudit(h.store, c, "recheck", "merchant", fmt.Sprintf("%d", id), gin.H{"tg_username": merchant.TgUsername})
  748. OK(c, gin.H{"message": "已标记为待重新检查", "merchant_id": id})
  749. }
  750. // BatchRecheck handles POST /merchants/clean/batch-recheck — batch re-check
  751. func (h *MerchantHandler) BatchRecheck(c *gin.Context) {
  752. var body struct {
  753. IDs []uint `json:"ids" binding:"required"`
  754. }
  755. if err := c.ShouldBindJSON(&body); err != nil {
  756. Fail(c, 400, err.Error())
  757. return
  758. }
  759. now := time.Now()
  760. h.store.DB.Model(&model.MerchantClean{}).Where("id IN ?", body.IDs).
  761. Updates(map[string]interface{}{"last_checked_at": now})
  762. LogAudit(h.store, c, "recheck", "merchant", fmt.Sprintf("batch:%d", len(body.IDs)), nil)
  763. OK(c, gin.H{"updated": len(body.IDs)})
  764. }
  765. // MergeMerchants handles POST /merchants/clean/merge — merges secondary into primary
  766. func (h *MerchantHandler) MergeMerchants(c *gin.Context) {
  767. var body struct {
  768. PrimaryID uint `json:"primary_id" binding:"required"`
  769. SecondaryID uint `json:"secondary_id" binding:"required"`
  770. }
  771. if err := c.ShouldBindJSON(&body); err != nil {
  772. Fail(c, 400, err.Error())
  773. return
  774. }
  775. if body.PrimaryID == body.SecondaryID {
  776. Fail(c, 400, "不能合并同一个商户")
  777. return
  778. }
  779. var primary, secondary model.MerchantClean
  780. if err := h.store.DB.First(&primary, body.PrimaryID).Error; err != nil {
  781. Fail(c, 404, "主商户不存在")
  782. return
  783. }
  784. if err := h.store.DB.First(&secondary, body.SecondaryID).Error; err != nil {
  785. Fail(c, 404, "副商户不存在")
  786. return
  787. }
  788. // Fill empty fields from secondary
  789. if primary.MerchantName == "" && secondary.MerchantName != "" {
  790. primary.MerchantName = secondary.MerchantName
  791. }
  792. if primary.Website == "" && secondary.Website != "" {
  793. primary.Website = secondary.Website
  794. }
  795. if primary.Email == "" && secondary.Email != "" {
  796. primary.Email = secondary.Email
  797. }
  798. if primary.Phone == "" && secondary.Phone != "" {
  799. primary.Phone = secondary.Phone
  800. }
  801. if primary.IndustryTag == "" && secondary.IndustryTag != "" {
  802. primary.IndustryTag = secondary.IndustryTag
  803. }
  804. // Merge sources
  805. var primarySources, secondarySources []map[string]string
  806. json.Unmarshal(primary.AllSources, &primarySources)
  807. json.Unmarshal(secondary.AllSources, &secondarySources)
  808. allSources := append(primarySources, secondarySources...)
  809. sourcesJSON, _ := json.Marshal(allSources)
  810. primary.AllSources = sourcesJSON
  811. primary.SourceCount = len(allSources)
  812. // Use the better level
  813. levelRank := map[string]int{"Hot": 3, "Warm": 2, "Cold": 1}
  814. if levelRank[secondary.Level] > levelRank[primary.Level] {
  815. primary.Level = secondary.Level
  816. }
  817. // Save primary
  818. if err := h.store.DB.Save(&primary).Error; err != nil {
  819. Fail(c, 500, err.Error())
  820. return
  821. }
  822. // Transfer notes from secondary to primary
  823. h.store.DB.Model(&model.MerchantNote{}).
  824. Where("merchant_id = ?", secondary.ID).
  825. Update("merchant_id", primary.ID)
  826. // Delete secondary
  827. h.store.DB.Delete(&secondary)
  828. LogAudit(h.store, c, "merge", "merchant",
  829. fmt.Sprintf("%d←%d", primary.ID, secondary.ID),
  830. gin.H{"primary": primary.ID, "secondary": secondary.ID})
  831. OK(c, primary)
  832. }
  833. // ListIndustryTags handles GET /merchants/clean/industry-tags — returns distinct industry tags
  834. func (h *MerchantHandler) ListIndustryTags(c *gin.Context) {
  835. var tags []string
  836. h.store.DB.Model(&model.MerchantClean{}).
  837. Where("industry_tag != ''").
  838. Distinct("industry_tag").
  839. Pluck("industry_tag", &tags)
  840. OK(c, tags)
  841. }
  842. // ListUsers handles GET /merchants/clean/users — returns operator/admin usernames for assignment dropdown
  843. func (h *MerchantHandler) ListUsers(c *gin.Context) {
  844. var users []struct {
  845. Username string `json:"username"`
  846. Nickname string `json:"nickname"`
  847. }
  848. h.store.DB.Model(&model.User{}).
  849. Where("enabled = ? AND role IN ?", true, []string{"admin", "operator"}).
  850. Select("username, nickname").
  851. Find(&users)
  852. OK(c, users)
  853. }
  854. // AddNote handles POST /merchants/clean/:id/notes
  855. func (h *MerchantHandler) AddNote(c *gin.Context) {
  856. id, err := strconv.ParseUint(c.Param("id"), 10, 64)
  857. if err != nil {
  858. Fail(c, 400, "invalid id")
  859. return
  860. }
  861. var body struct {
  862. Content string `json:"content" binding:"required"`
  863. }
  864. if err := c.ShouldBindJSON(&body); err != nil {
  865. Fail(c, 400, err.Error())
  866. return
  867. }
  868. // Look up the merchant to get tg_username
  869. var merchant model.MerchantClean
  870. if err := h.store.DB.First(&merchant, id).Error; err != nil {
  871. Fail(c, 404, "merchant not found")
  872. return
  873. }
  874. note := model.MerchantNote{
  875. MerchantID: uint(id),
  876. TgUsername: merchant.TgUsername,
  877. Content: body.Content,
  878. CreatedBy: c.GetString("username"),
  879. }
  880. if err := h.store.DB.Create(&note).Error; err != nil {
  881. Fail(c, 500, err.Error())
  882. return
  883. }
  884. OK(c, note)
  885. }