Files
cmautobuy/admin/handler/web/web.go
T
chengmaandClaude Opus 5 eb8d357f5a 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>
2026-08-07 11:27:06 +08:00

99 lines
3.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package web 是给浏览器用的页面处理器。
//
// 和 handler/api 分开是有意的:页面出错要渲染错误页、要 CSRF 防护,
// 接口出错要返回 JSON、要认证。混在一起迟早写错。
//
// 改动本文件前必读 admin/AGENTS.md。两条最容易踩:
// - 本层**不写业务逻辑、不拼 SQL**,只做取参数 → 调 service → 渲染;
// - 删除、导入这类破坏性操作用 POST,**不得用 GET**,
// 浏览器和插件会预取 GET 链接。
package web
import (
"database/sql"
"net/http"
"time"
"github.com/gin-gonic/gin"
)
// Handler 持有各页面共用的依赖。
type Handler struct {
db *sql.DB
// onlineThreshold 是判定客户端"在线"的时间窗。
// 取「轮询周期 + 最长任务时长」,宽松一点,
// 免得客户端执行长任务期间被误判成离线。
onlineThreshold time.Duration
}
// 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}
// CSRF 只挂在页面路由上。
// 给 Client 的 /api/v1/client/* 绝不能加——它不是浏览器、没有 Cookie。
pages := r.Group("/", CSRFMiddleware())
// 打开根路径直接进第一个模块
pages.GET("/", func(c *gin.Context) {
c.Redirect(http.StatusFound, "/shopee")
})
// 1. 蝦皮数据
pages.GET("/shopee", h.ShopeeList)
pages.POST("/shopee/import", h.ShopeeImport)
pages.POST("/shopee/save", h.ShopeeSave)
pages.POST("/shopee/delete", h.ShopeeDelete)
pages.POST("/shopee/collect", h.ShopeeCollect)
// 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)
// 4. 采购任务
pages.GET("/tasks", h.TaskList)
pages.POST("/tasks/delete", h.TaskDelete)
// 5. 客户端列表
pages.GET("/clients", h.ClientList)
pages.POST("/clients/delete", h.ClientDelete)
}
// page 组装每个页面都要的公共数据(导航高亮、标题、CSRF token)。
func page(c *gin.Context, active, title string, extra gin.H) gin.H {
data := gin.H{
"Active": active,
"Title": title,
"CSRFToken": csrfToken(c),
}
for k, v := range extra {
data[k] = v
}
return data
}
// fail 渲染一个错误页。
//
// 错误信息要说清三件事:发生了什么、保住了什么、下一步做什么。
// Go 的错误堆栈只写日志,不要贴到页面上,见 docs/admin/06-quality-security.md §5。
func fail(c *gin.Context, status int, message string) {
c.HTML(status, "partials/error", gin.H{
"Title": "出错了",
"Message": message,
})
}