Files
cmautobuy/admin/handler/web/pdd.go
T
chengmaandClaude Opus 5 e40ce5a13c fix: 商品卡在「采集中」无法恢复 (#24)
MarkCollecting 原本只在 pending/failed 时成功,而全库只有客户端提交结果
才会把 collecting 改走。客户端离线、崩溃、任务被删都是常态——一旦发生,
这个商品就永久报废,界面上没有任何入口能救,只能改数据库。

改成 collecting 超过 15 分钟视为已超时,允许重新创建采集任务。

判定在读取那一刻现算,仍是同一条原子 UPDATE,不加后台清理协程:
后台扫要处理"扫到一半客户端正好提交了"的竞态,本项目已经在并发上
栽过两次,能不引入并发就不引入。

15 分钟写成有名字的常量并注明理由:采集本身几十秒到两分钟,加上排队
等客户端来领。宁可短也不要长——采集是只读的,多采一次没有副作用,
而卡死的代价是商品永久报废。

已知取舍:超时后老任务还在队列里,客户端上线可能两个都领走、采两次。
可接受(只读,后一次覆盖前一次),已写进代码注释和 03-data-model.md,
免得后来人当成 bug 去"修"成加锁或加租约——租约和心跳是被明确移除的设计。

时间比较用字符串,依赖 NowISO 产出定宽 UTC。已在 NowISO 上加注释:
改成带时区偏移的本地时间会让这个比较静默失效,不报错但判断全错。

界面两处:超时的显示「采集中(超时)」,否则操作员盯着「采集中」
不知道它已经死了;跳过原因按正在采集/已采集/已删除分类计数,
一个都没建成时告诉操作员还要等多久。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 14:42:54 +08:00

169 lines
5.4 KiB
Go

package web
import (
"fmt"
"net/http"
"net/url"
"strconv"
"github.com/gin-gonic/gin"
"cmautobuy/admin/service"
)
// PDD 商品页的处理器。
//
// 本文件只做三件事:取参数 → 调 service → 渲染或跳转。
// 不写业务判断,不拼 SQL——那些在 service/pdd.go 和 repository/pdd.go 里。
// PddList 渲染 PDD 商品列表页。
func (h *Handler) PddList(c *gin.Context) {
keyword := c.Query("q")
status := service.ParseCollectStatus(c.Query("status"))
result, err := service.ListPddProducts(h.db, keyword, status)
if err != nil {
fail(c, http.StatusInternalServerError,
"读取 PDD 商品列表失败,数据没有被改动。刷新页面重试;一直失败请把这句话报给维护者。")
return
}
// 底部状态条平时显示统计,刚做完操作时先显示操作结果。
// 操作结果是通过跳转带过来的 msg 参数传的(见 pddRedirect)。
statusLine := result.StatusLine()
if msg := c.Query("msg"); msg != "" {
statusLine = msg + " · " + statusLine
}
c.HTML(http.StatusOK, "pdd/list", page(c, "pdd", "PDD 商品", gin.H{
"Rows": result.Rows,
"Keyword": keyword,
"Status": statusLine,
"StatusFilter": string(status),
"StatusOptions": service.CollectStatusOptions(),
"HasAnyProducts": result.Total > 0,
"IsFiltered": result.IsFiltered,
}))
}
// PddDetail 渲染双击行弹出的那个弹窗的**内容**(不是整页)。
//
// 弹窗内容由服务端渲染,前端只负责把它放进弹窗壳子里显示出来。
// 不要改成"前端拿 JSON 自己拼表格"——规格表的维度顺序、
// 价格格式、null 的处理都是业务规则,散到前端就没人维护得住了。
func (h *Handler) PddDetail(c *gin.Context) {
id, err := strconv.ParseInt(c.Query("id"), 10, 64)
if err != nil {
fail(c, http.StatusBadRequest, "商品编号不对,请刷新页面后重试。")
return
}
detail, err := service.GetPddProductDetail(h.db, id)
if err != nil {
fail(c, http.StatusInternalServerError, "读取商品详情失败,数据没有被改动。")
return
}
if detail == nil {
fail(c, http.StatusNotFound, "这个商品不存在或已被删除,请刷新页面。")
return
}
c.HTML(http.StatusOK, "pdd/edit_modal", gin.H{
"D": detail,
"CSRFToken": csrfToken(c),
})
}
// PddCreate 按链接创建一条 PDD 商品。
//
// 只填链接,其余字段全靠采集回填。
// 链接解析不出 goods_id 时**报错并且不写库**,见 service.ParsePddGoodsID。
func (h *Handler) PddCreate(c *gin.Context) {
goodsID, err := service.CreatePddProduct(h.db, c.PostForm("url"))
if err != nil {
fail(c, http.StatusBadRequest, err.Error()+"。没有创建任何记录。")
return
}
h.pddRedirect(c, fmt.Sprintf("已保存商品 %s,现在可以勾选它创建采集任务", goodsID))
}
// PddSave 保存弹窗里改过的链接。
func (h *Handler) PddSave(c *gin.Context) {
id, err := strconv.ParseInt(c.PostForm("id"), 10, 64)
if err != nil {
fail(c, http.StatusBadRequest, "商品编号不对,请刷新页面后重试。没有改动任何数据。")
return
}
if err := service.UpdatePddProductURL(h.db, id, c.PostForm("url")); err != nil {
fail(c, http.StatusBadRequest, err.Error()+"。没有改动任何数据。")
return
}
h.pddRedirect(c, "链接已保存")
}
// PddCollect 为勾选的商品创建采集任务。
//
// 按钮文案是「创建采集任务」而不是「采集」:它做的是建一个任务,
// 不是立刻去采。真正的采集要等客户端来领、去手机上跑,
// 可能几秒也可能几分钟。
func (h *Handler) PddCollect(c *gin.Context) {
ids := c.PostFormArray("ids")
if len(ids) == 0 {
h.pddRedirect(c, "没有勾选任何商品,没有创建任务")
return
}
result, err := service.CreatePddCollectTasks(h.db, ids)
if err != nil {
fail(c, http.StatusInternalServerError,
"创建采集任务失败:"+err.Error()+"。这一批任务整体没有创建,可以直接重试。")
return
}
// 跳过原因分类、"还要等多久"的措辞都是业务规则,交给 service 算,
// 这里只负责把结果接过来发给操作员,见 service.FormatCollectTaskMessage。
h.pddRedirect(c, service.FormatCollectTaskMessage(result))
}
// PddDelete 批量软删除。
func (h *Handler) PddDelete(c *gin.Context) {
ids := c.PostFormArray("ids")
if len(ids) == 0 {
h.pddRedirect(c, "没有勾选任何商品,没有删除")
return
}
n, err := service.DeletePddProducts(h.db, ids)
if err != nil {
fail(c, http.StatusInternalServerError,
"删除失败:"+err.Error()+"。没有删除任何记录。")
return
}
h.pddRedirect(c, fmt.Sprintf("已删除 %d 条", n))
}
// pddRedirect 处理完写操作后跳回列表页。
//
// 用 303 跳转而不是直接渲染,是为了让浏览器地址栏变成 GET /pdd——
// 否则用户按 F5 会重复提交刚才那个 POST(重复建任务、重复删除)。
//
// 筛选条件一起带回去,免得操作员每做一次操作就要重新筛一遍。
func (h *Handler) pddRedirect(c *gin.Context, msg string) {
params := url.Values{}
if s := c.PostForm("status"); s != "" {
params.Set("status", s)
}
if q := c.PostForm("q"); q != "" {
params.Set("q", q)
}
if msg != "" {
params.Set("msg", msg)
}
target := "/pdd"
if len(params) > 0 {
target += "?" + params.Encode()
}
c.Redirect(http.StatusSeeOther, target)
}