2026-08-07 11:27:06 +08:00
|
|
|
package web
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"fmt"
|
2026-08-10 16:14:24 +08:00
|
|
|
"io"
|
2026-08-07 11:27:06 +08:00
|
|
|
"net/http"
|
|
|
|
|
"net/url"
|
2026-08-10 16:14:24 +08:00
|
|
|
"os"
|
2026-08-07 11:27:06 +08:00
|
|
|
"strconv"
|
|
|
|
|
|
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
|
|
2026-08-10 16:14:24 +08:00
|
|
|
"cmautobuy/admin/config"
|
2026-08-07 11:27:06 +08:00
|
|
|
"cmautobuy/admin/service"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// PDD 商品页的处理器。
|
|
|
|
|
//
|
|
|
|
|
// 本文件只做三件事:取参数 → 调 service → 渲染或跳转。
|
|
|
|
|
// 不写业务判断,不拼 SQL——那些在 service/pdd.go 和 repository/pdd.go 里。
|
|
|
|
|
|
|
|
|
|
// PddList 渲染 PDD 商品列表页。
|
|
|
|
|
func (h *Handler) PddList(c *gin.Context) {
|
2026-08-10 16:14:24 +08:00
|
|
|
h.renderPddList(c, c.Query("q"), c.Query("status"), c.Query("page"), "", nil)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// renderPddList 统一渲染普通列表和导入结果页,避免两条入口逐渐长成两套页面。
|
|
|
|
|
func (h *Handler) renderPddList(c *gin.Context, keyword, statusRaw, pageRaw, message string, importResult *service.PddImportResult) {
|
|
|
|
|
status := service.ParseCollectStatus(statusRaw)
|
|
|
|
|
pageNum := service.ParsePage(pageRaw)
|
2026-08-15 15:15:38 +08:00
|
|
|
pageSizeRaw := c.Query("page_size")
|
|
|
|
|
if pageSizeRaw == "" && c.Request.Method == http.MethodPost {
|
|
|
|
|
pageSizeRaw = c.PostForm("page_size")
|
|
|
|
|
}
|
|
|
|
|
pageSize := service.ParsePageSize(pageSizeRaw)
|
2026-08-07 11:27:06 +08:00
|
|
|
|
2026-08-15 15:15:38 +08:00
|
|
|
result, err := service.ListPddProductsWithPageSize(h.db, keyword, status, pageNum, pageSize)
|
2026-08-07 11:27:06 +08:00
|
|
|
if err != nil {
|
|
|
|
|
fail(c, http.StatusInternalServerError,
|
|
|
|
|
"读取 PDD 商品列表失败,数据没有被改动。刷新页面重试;一直失败请把这句话报给维护者。")
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-08-10 00:49:51 +08:00
|
|
|
assignableClients, err := service.ListAssignableClients(h.db, currentUser(c), h.onlineThreshold)
|
|
|
|
|
if err != nil {
|
|
|
|
|
fail(c, http.StatusInternalServerError,
|
|
|
|
|
"读取可选客户端失败,数据没有被改动。刷新页面重试。")
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-08-07 11:27:06 +08:00
|
|
|
|
|
|
|
|
// 底部状态条平时显示统计,刚做完操作时先显示操作结果。
|
|
|
|
|
// 操作结果是通过跳转带过来的 msg 参数传的(见 pddRedirect)。
|
|
|
|
|
statusLine := result.StatusLine()
|
2026-08-10 16:14:24 +08:00
|
|
|
if msg := c.Query("msg"); msg != "" && message == "" {
|
|
|
|
|
message = msg
|
|
|
|
|
}
|
|
|
|
|
if message != "" {
|
|
|
|
|
statusLine = message + " · " + statusLine
|
2026-08-07 11:27:06 +08:00
|
|
|
}
|
|
|
|
|
|
2026-08-09 21:51:20 +08:00
|
|
|
values := url.Values{}
|
|
|
|
|
if keyword != "" {
|
|
|
|
|
values.Set("q", keyword)
|
|
|
|
|
}
|
|
|
|
|
if status != "" {
|
|
|
|
|
values.Set("status", string(status))
|
|
|
|
|
}
|
2026-08-15 15:15:38 +08:00
|
|
|
values.Set("page_size", strconv.Itoa(pageSize))
|
2026-08-09 21:51:20 +08:00
|
|
|
detailValues := url.Values{}
|
|
|
|
|
for key, entries := range values {
|
|
|
|
|
detailValues[key] = append([]string(nil), entries...)
|
|
|
|
|
}
|
|
|
|
|
detailValues.Set("page", strconv.Itoa(result.Page))
|
2026-08-07 11:27:06 +08:00
|
|
|
c.HTML(http.StatusOK, "pdd/list", page(c, "pdd", "PDD 商品", gin.H{
|
2026-08-10 00:49:51 +08:00
|
|
|
"Rows": result.Rows,
|
|
|
|
|
"Keyword": keyword,
|
|
|
|
|
"Status": statusLine,
|
|
|
|
|
"StatusFilter": string(status),
|
|
|
|
|
"StatusOptions": service.CollectStatusOptions(),
|
|
|
|
|
"HasAnyProducts": result.Total > 0,
|
|
|
|
|
"IsFiltered": result.IsFiltered,
|
|
|
|
|
"CurrentPage": result.Page,
|
2026-08-15 15:15:38 +08:00
|
|
|
"Pagination": service.NewPaginationView(result.Page, pageSize, result.TotalPages, values.Encode()),
|
|
|
|
|
"CurrentPageSize": pageSize,
|
2026-08-10 00:49:51 +08:00
|
|
|
"DetailURL": "/pdd/detail?" + detailValues.Encode(),
|
|
|
|
|
"AssignableClients": assignableClients,
|
2026-08-10 16:14:24 +08:00
|
|
|
"ImportResult": importResult,
|
2026-08-07 11:27:06 +08:00
|
|
|
}))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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
|
|
|
|
|
}
|
2026-08-10 19:38:40 +08:00
|
|
|
assignableClients, err := service.ListAssignableClients(h.db, currentUser(c), h.onlineThreshold)
|
|
|
|
|
if err != nil {
|
|
|
|
|
fail(c, http.StatusInternalServerError, "读取可选客户端失败,数据没有被改动。刷新页面重试。")
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-08-07 11:27:06 +08:00
|
|
|
|
|
|
|
|
c.HTML(http.StatusOK, "pdd/edit_modal", gin.H{
|
2026-08-10 19:38:40 +08:00
|
|
|
"D": detail,
|
|
|
|
|
"CSRFToken": csrfToken(c),
|
|
|
|
|
"AssignableClients": assignableClients,
|
|
|
|
|
"Keyword": c.Query("q"), "StatusFilter": c.Query("status"),
|
2026-08-15 15:15:38 +08:00
|
|
|
"CurrentPage": service.ParsePage(c.Query("page")),
|
|
|
|
|
"CurrentPageSize": service.ParsePageSize(c.Query("page_size")),
|
2026-08-07 11:27:06 +08:00
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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))
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-10 16:14:24 +08:00
|
|
|
// PddImport 把一列式 xlsx 中的完整 PDD 链接批量导入商品档案。
|
|
|
|
|
// 上传只建档,不会自动创建采集任务;导入结果页提供单独的确认入口。
|
|
|
|
|
func (h *Handler) PddImport(c *gin.Context) {
|
|
|
|
|
fileHeader, err := c.FormFile("file")
|
|
|
|
|
if err != nil {
|
|
|
|
|
fail(c, http.StatusBadRequest,
|
|
|
|
|
"没有收到文件(也可能是文件超过了 10MB 上限)。请重新选择 Excel;没有写入任何商品。")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
src, err := fileHeader.Open()
|
|
|
|
|
if err != nil {
|
|
|
|
|
fail(c, http.StatusBadRequest, "无法打开上传文件,请重新选择;没有写入任何商品。")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
defer src.Close()
|
|
|
|
|
|
|
|
|
|
head := make([]byte, 8)
|
|
|
|
|
n, _ := io.ReadFull(src, head)
|
|
|
|
|
if err := service.ValidatePddUpload(fileHeader.Filename, fileHeader.Size, head[:n]); err != nil {
|
|
|
|
|
fail(c, http.StatusBadRequest, err.Error()+"。请按模板选择文件;没有写入任何商品。")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if _, err := src.Seek(0, io.SeekStart); err != nil {
|
|
|
|
|
fail(c, http.StatusInternalServerError, "读取上传文件失败,请重试;没有写入任何商品。")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
uploadDir, err := config.SubDir("uploads")
|
|
|
|
|
if err != nil {
|
|
|
|
|
fail(c, http.StatusInternalServerError, "无法准备临时上传目录,请重试;没有写入任何商品。")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
tmp, err := os.CreateTemp(uploadDir, "pdd-*.xlsx")
|
|
|
|
|
if err != nil {
|
|
|
|
|
fail(c, http.StatusInternalServerError, "无法保存临时上传文件,请重试;没有写入任何商品。")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
tmpPath := tmp.Name()
|
|
|
|
|
defer os.Remove(tmpPath)
|
|
|
|
|
if _, err := io.Copy(tmp, src); err != nil {
|
|
|
|
|
tmp.Close()
|
|
|
|
|
fail(c, http.StatusInternalServerError, "保存临时上传文件失败,请重试;没有写入任何商品。")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if err := tmp.Close(); err != nil {
|
|
|
|
|
fail(c, http.StatusInternalServerError, "保存临时上传文件失败,请重试;没有写入任何商品。")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
result, err := service.ImportPddExcel(h.db, tmpPath)
|
|
|
|
|
if err != nil {
|
|
|
|
|
if service.IsInvalidPddImport(err) {
|
|
|
|
|
fail(c, http.StatusBadRequest, err.Error()+"。请修正文件后重新导入;没有写入任何商品。")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
fail(c, http.StatusInternalServerError,
|
|
|
|
|
"PDD 商品导入时数据库写入失败,本批合法行已经整体回滚。请稍后重试;一直失败请联系维护者。")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
message := fmt.Sprintf(
|
|
|
|
|
"批量导入完成:总行数 %d,新建 %d,已存在 %d,恢复 %d,文件内重复 %d,失败 %d",
|
|
|
|
|
result.TotalRows, result.CreatedCount, result.ExistingCount, result.RevivedCount,
|
|
|
|
|
result.DuplicateCount, len(result.Failures))
|
|
|
|
|
h.renderPddList(c, "", "", "1", message, result)
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-07 11:27:06 +08:00
|
|
|
// 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
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-10 00:49:51 +08:00
|
|
|
result, err := service.CreatePddCollectTasksForUser(h.db, currentUser(c), ids, c.PostForm("client_id"))
|
2026-08-07 11:27:06 +08:00
|
|
|
if err != nil {
|
2026-08-10 00:49:51 +08:00
|
|
|
status := http.StatusInternalServerError
|
|
|
|
|
message := "创建采集任务失败,系统没有改动这一批数据。请稍后重试。"
|
|
|
|
|
if service.IsValidationError(err) {
|
|
|
|
|
status = http.StatusBadRequest
|
|
|
|
|
message = err.Error() + "。这一批任务整体没有创建,请重新选择。"
|
|
|
|
|
}
|
|
|
|
|
fail(c, status, message)
|
2026-08-07 11:27:06 +08:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-07 14:42:54 +08:00
|
|
|
// 跳过原因分类、"还要等多久"的措辞都是业务规则,交给 service 算,
|
|
|
|
|
// 这里只负责把结果接过来发给操作员,见 service.FormatCollectTaskMessage。
|
|
|
|
|
h.pddRedirect(c, service.FormatCollectTaskMessage(result))
|
2026-08-07 11:27:06 +08:00
|
|
|
}
|
|
|
|
|
|
2026-08-10 19:38:40 +08:00
|
|
|
// PddRecollect 处理商品详情里操作员明确点击的“重新采集”。
|
|
|
|
|
// 它与批量创建任务分开,避免普通批量入口意外重采所有已完成商品。
|
|
|
|
|
func (h *Handler) PddRecollect(c *gin.Context) {
|
|
|
|
|
result, err := service.RecollectPddProductForUser(
|
|
|
|
|
h.db, currentUser(c), c.PostForm("goods_id"), c.PostForm("client_id"))
|
|
|
|
|
if err != nil {
|
|
|
|
|
status := http.StatusInternalServerError
|
|
|
|
|
message := "创建重新采集任务失败,商品和任务均未改动。请稍后重试。"
|
|
|
|
|
if service.IsValidationError(err) {
|
|
|
|
|
status = http.StatusBadRequest
|
|
|
|
|
message = err.Error() + "。没有创建任务。"
|
|
|
|
|
}
|
|
|
|
|
fail(c, status, message)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
h.pddRedirect(c, service.FormatCollectTaskMessage(result))
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-07 11:27:06 +08:00
|
|
|
// 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)
|
|
|
|
|
}
|
2026-08-09 21:51:20 +08:00
|
|
|
if p := c.PostForm("page"); p != "" {
|
|
|
|
|
params.Set("page", p)
|
|
|
|
|
}
|
2026-08-15 15:15:38 +08:00
|
|
|
params.Set("page_size", strconv.Itoa(service.ParsePageSize(c.PostForm("page_size"))))
|
2026-08-07 11:27:06 +08:00
|
|
|
if msg != "" {
|
|
|
|
|
params.Set("msg", msg)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
target := "/pdd"
|
|
|
|
|
if len(params) > 0 {
|
|
|
|
|
target += "?" + params.Encode()
|
|
|
|
|
}
|
|
|
|
|
c.Redirect(http.StatusSeeOther, target)
|
|
|
|
|
}
|