feat: 建立全局店铺跨渠道管理 (#208 #209 #210 #211)

This commit is contained in:
chengma
2026-08-13 11:15:54 +08:00
parent 901b37a8cb
commit 8f707722ba
28 changed files with 1160 additions and 500 deletions
+112
View File
@@ -0,0 +1,112 @@
package web
import (
"net/http"
"net/url"
"time"
"github.com/gin-gonic/gin"
"cmautobuy/admin/service"
)
func (h *Handler) ShopList(c *gin.Context) {
result, err := service.ListShops(h.db, currentUser(c))
if err != nil {
fail(c, http.StatusInternalServerError, "读取店铺管理数据失败,数据没有被改动。")
return
}
c.HTML(http.StatusOK, "shop/list", page(c, "shops", "店铺管理", gin.H{
"Rows": result.Items, "UnlinkedShopeeCount": result.UnlinkedShopeeCount,
"Message": c.Query("msg"), "Error": c.Query("error"),
}))
}
func (h *Handler) ShopCreate(c *gin.Context) {
err := service.CreateShop(h.db, currentUser(c), c.PostForm("display_name"),
c.PostForm("syb_alias"), c.PostForm("shopee_alias"), time.Now())
if err != nil {
if service.IsValidationError(err) {
redirectShops(c, "", err.Error())
return
}
fail(c, http.StatusInternalServerError, "新增店铺失败,已有数据没有被改动。")
return
}
redirectShops(c, "店铺已新增;历史数据已按渠道原始名称精确关联", "")
}
func (h *Handler) ShopUpdate(c *gin.Context) {
err := service.UpdateShop(h.db, currentUser(c), c.PostForm("shop_id"), c.PostForm("display_name"),
c.PostForm("syb_alias"), c.PostForm("shopee_alias"), time.Now())
if err != nil {
if service.IsValidationError(err) {
redirectShops(c, "", err.Error())
return
}
fail(c, http.StatusInternalServerError, "保存店铺失败,原配置保持不变。")
return
}
redirectShops(c, "店铺名称和渠道关联已保存", "")
}
func (h *Handler) ShopStatus(c *gin.Context) {
enabled := c.PostForm("enabled") == "1"
if err := service.SetShopEnabled(h.db, currentUser(c), c.PostForm("shop_id"), enabled, time.Now()); err != nil {
if service.IsValidationError(err) {
redirectShops(c, "", err.Error())
return
}
fail(c, http.StatusInternalServerError, "更新店铺状态失败,原状态保持不变。")
return
}
message := "业务店铺已停用;SYB 不再同步该店铺"
if enabled {
message = "业务店铺已启用"
}
redirectShops(c, message, "")
}
func (h *Handler) ShopSybStatus(c *gin.Context) {
enabled := c.PostForm("enabled") == "1"
if err := service.SetShopSybEnabled(h.db, currentUser(c), c.PostForm("shop_id"), enabled, time.Now()); err != nil {
if service.IsValidationError(err) {
redirectShops(c, "", err.Error())
return
}
fail(c, http.StatusInternalServerError, "更新 SYB 同步状态失败,原状态保持不变。")
return
}
message := "SYB 同步已停用"
if enabled {
message = "SYB 同步已启用"
}
redirectShops(c, message, "")
}
func (h *Handler) ShopDelete(c *gin.Context) {
if err := service.DeleteShop(h.db, currentUser(c), c.PostForm("shop_id")); err != nil {
if service.IsValidationError(err) {
redirectShops(c, "", err.Error())
return
}
fail(c, http.StatusInternalServerError, "删除店铺失败,已有数据没有被改动。")
return
}
redirectShops(c, "已删除没有关联数据的停用店铺", "")
}
func redirectShops(c *gin.Context, message, errorMessage string) {
values := url.Values{}
if message != "" {
values.Set("msg", message)
}
if errorMessage != "" {
values.Set("error", errorMessage)
}
target := "/shops"
if encoded := values.Encode(); encoded != "" {
target += "?" + encoded
}
c.Redirect(http.StatusSeeOther, target)
}
+17 -2
View File
@@ -18,7 +18,7 @@ import (
// **商品级一行**(不是 SKU 级),数据来自 shopee_products 和 shopee_skus
// 聚合联查。列定义见 docs/admin/05-ui-specification.md §4.2、工单 #41。
func (h *Handler) ShopeeList(c *gin.Context) {
h.renderShopeeList(c, c.Query("goods_id"), c.Query("shop_name"), c.Query("status"), c.Query("shop"), c.Query("image"), c.Query("deleted"), c.Query("page"), c.Query("msg"))
h.renderShopeeList(c, c.Query("goods_id"), c.Query("shop_name"), c.Query("status"), c.Query("shop"), c.Query("image"), c.Query("store"), c.Query("deleted"), c.Query("page"), c.Query("msg"))
}
// ShopeeDetail 渲染双击行弹出的那个弹窗的**内容**(不是整页)。
@@ -49,6 +49,7 @@ func (h *Handler) ShopeeDetail(c *gin.Context) {
"ShopNameKeyword": strings.TrimSpace(c.Query("shop_name")),
"ShopFilter": service.ParseShopeePresence(c.Query("shop")),
"ImageFilter": service.ParseShopeePresence(c.Query("image")),
"StoreFilter": strings.TrimSpace(c.Query("store")),
"CurrentPage": service.ParsePage(c.Query("page")),
})
}
@@ -57,13 +58,14 @@ func (h *Handler) ShopeeDetail(c *gin.Context) {
//
// `[必须]` 分页控件用 <a href> 纯 GET 导航,翻页要保留关键词和三个下拉筛选,
// 所以这里把归一后的有效值透传回模板去拼下一页的链接(工单 #43、#150)。
func (h *Handler) renderShopeeList(c *gin.Context, keyword, shopName, statusRaw, shopRaw, imageRaw, deletedRaw, pageRaw, msg string) {
func (h *Handler) renderShopeeList(c *gin.Context, keyword, shopName, statusRaw, shopRaw, imageRaw, storeRaw, deletedRaw, pageRaw, msg string) {
filter := repository.ShopeeFilter{
Keyword: keyword,
ShopName: strings.TrimSpace(shopName),
Status: service.ParseShopeeStatus(statusRaw),
Shop: service.ParseShopeePresence(shopRaw),
Image: service.ParseShopeePresence(imageRaw),
StoreID: strings.TrimSpace(storeRaw),
}
if currentUser(c).IsAdmin() && deletedRaw == "1" {
filter.Deleted = true
@@ -84,6 +86,11 @@ func (h *Handler) renderShopeeList(c *gin.Context, keyword, shopName, statusRaw,
"读取可选客户端失败,数据没有被改动。刷新页面重试。")
return
}
shopOptions, err := service.ShopOptions(h.db)
if err != nil {
fail(c, http.StatusInternalServerError, "读取业务店铺失败,数据没有被改动。")
return
}
status := msg
if status == "" {
@@ -106,6 +113,9 @@ func (h *Handler) renderShopeeList(c *gin.Context, keyword, shopName, statusRaw,
if filter.Image != "" {
values.Set("image", filter.Image)
}
if filter.StoreID != "" {
values.Set("store", filter.StoreID)
}
if filter.Deleted {
values.Set("deleted", "1")
}
@@ -119,6 +129,8 @@ func (h *Handler) renderShopeeList(c *gin.Context, keyword, shopName, statusRaw,
"ShopOptions": service.ShopeePresenceOptions("有店铺", "无店铺"),
"ImageFilter": filter.Image,
"ImageOptions": service.ShopeePresenceOptions("有图片", "无图片"),
"StoreFilter": filter.StoreID,
"StoreOptions": shopOptions,
"DeletedFilter": filter.Deleted,
"Rows": result.Rows,
"Status": status,
@@ -253,6 +265,9 @@ func (h *Handler) shopeeRedirect(c *gin.Context, msg string) {
if value := service.ParseShopeePresence(c.PostForm("image")); value != "" {
params.Set("image", value)
}
if value := strings.TrimSpace(c.PostForm("store")); value != "" {
params.Set("store", value)
}
if currentUser(c).IsAdmin() && c.PostForm("deleted") == "1" {
params.Set("deleted", "1")
}
-89
View File
@@ -1,89 +0,0 @@
package web
import (
"errors"
"net/http"
"net/url"
"time"
"github.com/gin-gonic/gin"
"cmautobuy/admin/repository"
"cmautobuy/admin/service"
)
func (h *Handler) SybAllowedShopList(c *gin.Context) {
rows, err := service.ListSybAllowedShops(h.db, currentUser(c))
if err != nil {
fail(c, http.StatusInternalServerError, "读取顺运宝同步店铺失败,数据没有被改动。刷新后重试。")
return
}
enabled := 0
for _, row := range rows {
if row.Enabled {
enabled++
}
}
c.HTML(http.StatusOK, "syb/shops", page(c, "syb", "顺运宝同步店铺", gin.H{
"Rows": rows, "EnabledCount": enabled, "Message": c.Query("msg"), "Error": c.Query("error"),
}))
}
func (h *Handler) SybAllowedShopCreate(c *gin.Context) {
err := service.CreateSybAllowedShop(h.db, currentUser(c), c.PostForm("shop_name"), time.Now())
if err != nil {
if service.IsValidationError(err) || errors.Is(err, repository.ErrSybAllowedShopExists) {
redirectSybShops(c, "", err.Error())
return
}
fail(c, http.StatusInternalServerError, "新增同步店铺失败,已有店铺没有被改动。")
return
}
redirectSybShops(c, "店铺已加入允许列表并启用", "")
}
func (h *Handler) SybAllowedShopStatus(c *gin.Context) {
enabled := c.PostForm("enabled") == "1"
err := service.SetSybAllowedShopEnabled(h.db, currentUser(c), c.PostForm("shop_id"), enabled, time.Now())
if err != nil {
if service.IsValidationError(err) {
redirectSybShops(c, "", err.Error())
return
}
fail(c, http.StatusInternalServerError, "更新同步店铺失败,原状态保持不变。")
return
}
message := "店铺已停用,后续同步会跳过该店铺"
if enabled {
message = "店铺已重新启用"
}
redirectSybShops(c, message, "")
}
func (h *Handler) SybAllowedShopDelete(c *gin.Context) {
err := service.DeleteDisabledSybAllowedShop(h.db, currentUser(c), c.PostForm("shop_id"))
if err != nil {
if service.IsValidationError(err) {
redirectSybShops(c, "", err.Error())
return
}
fail(c, http.StatusInternalServerError, "删除同步店铺失败,已有配置和历史货运单没有被改动。")
return
}
redirectSybShops(c, "已删除停用店铺;历史货运单和同步记录保持不变", "")
}
func redirectSybShops(c *gin.Context, message, errorMessage string) {
values := url.Values{}
if message != "" {
values.Set("msg", message)
}
if errorMessage != "" {
values.Set("error", errorMessage)
}
target := "/syb/shops"
if encoded := values.Encode(); encoded != "" {
target += "?" + encoded
}
c.Redirect(http.StatusSeeOther, target)
}
+10 -5
View File
@@ -86,11 +86,16 @@ func Register(r *gin.Engine, db *sql.DB, onlineThreshold time.Duration) {
pages.POST("/syb/match", h.SybMatch)
pages.POST("/syb/create-task", h.SybCreateTask)
pages.POST("/syb/delete", h.SybDelete)
sybShops := pages.Group("/syb/shops", AdminRequired())
sybShops.GET("", h.SybAllowedShopList)
sybShops.POST("/create", h.SybAllowedShopCreate)
sybShops.POST("/status", h.SybAllowedShopStatus)
sybShops.POST("/delete", h.SybAllowedShopDelete)
pages.GET("/syb/shops", AdminRequired(), func(c *gin.Context) { c.Redirect(http.StatusMovedPermanently, "/shops") })
// 全局店铺管理:统一维护 SYB 与蝦皮渠道名称,仅管理员可写。
shops := pages.Group("/shops", AdminRequired())
shops.GET("", h.ShopList)
shops.POST("/create", h.ShopCreate)
shops.POST("/update", h.ShopUpdate)
shops.POST("/status", h.ShopStatus)
shops.POST("/syb-status", h.ShopSybStatus)
shops.POST("/delete", h.ShopDelete)
// 4. 采集采购
pages.GET("/tasks", h.TaskList)