feat: PDD 商品页面 (#18)
打通 Client↔Admin 闭环的一环:录入 PDD 链接 → 创建采集任务 → 客户端领走去采 → 规格和价格显示在页面上。#16 写的 MarkCollecting 和 SoftDeletePddProduct 至此才有生产调用方。 链接解析严格、不做容错兜底:goods_id 上有 UNIQUE 约束,防重全靠它。 猜一个的话同一商品会存成好几行、采好几遍,规格映射还说不清指向哪一行。 短链一律拒绝,卡域名是为了拦"粘了淘宝链接"这种失误。 创建采集任务先占状态再建任务:MarkCollecting 只在 pending/failed 时成功, 同时充当"有没有人已经在采"的判断,与建任务在同一事务里, 所以并发点多次只会建出一个任务(实测 4 并发 → 1 条)。 submit.go 的 collectedData 增加 Dimensions —— 没有它就只能按 Go 的 map 遍历,而 map 无序,同一商品每次刷新"颜色/尺码"的先后都可能变。 顺带修 #16 一处缺陷:EnsurePddProduct 复活分支清空了 skus_json 却漏了 title,导致复活后状态显示"未采集"但标题还留着旧值。 审查打回一次:清理"PDD 链接唯一的录入口"这一过期说法(全库 5 处), 以及 shopee.go 里"空 → no_link"的过期 TODO —— no_link 已在 #16 从 CHECK 约束删除,照写会直接撞约束。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,171 @@
|
||||
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
|
||||
}
|
||||
|
||||
created, skipped, err := service.CreatePddCollectTasks(h.db, ids)
|
||||
if err != nil {
|
||||
fail(c, http.StatusInternalServerError,
|
||||
"创建采集任务失败:"+err.Error()+"。这一批任务整体没有创建,可以直接重试。")
|
||||
return
|
||||
}
|
||||
|
||||
msg := fmt.Sprintf("已创建 %d 个采集任务,等待客户端领取", created)
|
||||
if skipped > 0 {
|
||||
// 跳过了几个必须说出来,否则操作员会以为都建上了,等半天没动静
|
||||
msg += fmt.Sprintf("(跳过 %d 个采集中或已删除的)", skipped)
|
||||
}
|
||||
h.pddRedirect(c, msg)
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
@@ -57,10 +57,17 @@ func (h *Handler) ShopeeImport(c *gin.Context) {
|
||||
|
||||
// ShopeeSave 保存编辑弹窗里的内容(PDD 链接、颜色、尺码、建议)。
|
||||
//
|
||||
// 这是 PDD 链接唯一的录入口,见 docs/admin/05-ui-specification.md §4.3。
|
||||
// 这是**从蝦皮商品出发**录入 PDD 链接的入口,
|
||||
// 见 docs/admin/05-ui-specification.md §4.3。PDD 商品页(§5)也能录入,
|
||||
// 两处并存;本页这个目前还是骨架,合并与否由「蝦皮↔PDD 关联入口」工单决定。
|
||||
func (h *Handler) ShopeeSave(c *gin.Context) {
|
||||
// TODO(骨架): 保存并根据 PDD 链接是否为空更新 collect_status
|
||||
// (空 -> no_link,非空且未采集 -> pending)
|
||||
// TODO(骨架): 保存 PDD 链接时调 repository.EnsurePddProduct,
|
||||
// 由它去建 / 复用 / 复活 pdd_products 那一行。
|
||||
//
|
||||
// **采集状态不在蝦皮这边**:它属于 PDD 商品(pdd_products.collect_status)。
|
||||
// "还没填链接"由 shopee_products.pdd_goods_id IS NULL 表达——
|
||||
// 没填链接时 pdd_products 里根本不会有那一行。
|
||||
// 不要写 no_link,那个值在 #16 已从 CHECK 约束里删掉了,写了会直接撞约束。
|
||||
fail(c, http.StatusNotImplemented, "保存功能尚未实现。")
|
||||
}
|
||||
|
||||
|
||||
@@ -26,9 +26,9 @@ type Handler struct {
|
||||
onlineThreshold time.Duration
|
||||
}
|
||||
|
||||
// Register 把四个模块的页面路由挂上去。
|
||||
// Register 把五个模块的页面路由挂上去。
|
||||
//
|
||||
// 四个模块的页面结构完全一致(顶部工具条 / 中间带勾选的表格 / 底部状态条),
|
||||
// 五个模块的页面结构完全一致(顶部工具条 / 中间带勾选的表格 / 底部状态条),
|
||||
// 这是有意的,见 docs/admin/05-ui-specification.md §2。
|
||||
func Register(r *gin.Engine, db *sql.DB, onlineThreshold time.Duration) {
|
||||
h := &Handler{db: db, onlineThreshold: onlineThreshold}
|
||||
@@ -49,18 +49,26 @@ func Register(r *gin.Engine, db *sql.DB, onlineThreshold time.Duration) {
|
||||
pages.POST("/shopee/delete", h.ShopeeDelete)
|
||||
pages.POST("/shopee/collect", h.ShopeeCollect)
|
||||
|
||||
// 2. 顺运宝数据
|
||||
// 2. PDD 商品
|
||||
pages.GET("/pdd", h.PddList)
|
||||
pages.GET("/pdd/detail", h.PddDetail) // 双击行时前端来取弹窗内容
|
||||
pages.POST("/pdd/create", h.PddCreate)
|
||||
pages.POST("/pdd/save", h.PddSave)
|
||||
pages.POST("/pdd/collect", h.PddCollect)
|
||||
pages.POST("/pdd/delete", h.PddDelete)
|
||||
|
||||
// 3. 顺运宝数据
|
||||
pages.GET("/syb", h.SybList)
|
||||
pages.POST("/syb/sync", h.SybSync)
|
||||
pages.POST("/syb/match", h.SybMatch)
|
||||
pages.POST("/syb/create-task", h.SybCreateTask)
|
||||
pages.POST("/syb/delete", h.SybDelete)
|
||||
|
||||
// 3. 采购任务
|
||||
// 4. 采购任务
|
||||
pages.GET("/tasks", h.TaskList)
|
||||
pages.POST("/tasks/delete", h.TaskDelete)
|
||||
|
||||
// 4. 客户端列表
|
||||
// 5. 客户端列表
|
||||
pages.GET("/clients", h.ClientList)
|
||||
pages.POST("/clients/delete", h.ClientDelete)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user