refactor(catalog): 废弃 LEWAN_ 镜像,统一英文 SKU 与积分价,商品图按 SKU 缓存
对外目录只保留英文主 sku;迁移硬删镜像并校准 1元=100积分;发货页/凭证优先使用 local {sku}.webp。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -72,7 +72,8 @@ func (h *DeliveryHandler) Submit(c *gin.Context) {
|
||||
response.OK(c, result)
|
||||
}
|
||||
|
||||
// ImageProxy 代理图片并支持本地持久化缓存,杜绝跨域污染和远端加载失败。
|
||||
// ImageProxy 代理外链图片并落盘缓存(仅无本地 SKU 图时的兜底)。
|
||||
// 正式商品图请使用 frontend/public/images/products/{sku}.webp(go run ./cmd/sync_product_images)。
|
||||
func (h *DeliveryHandler) ImageProxy(c *gin.Context) {
|
||||
rawURL := strings.TrimSpace(c.Query("url"))
|
||||
if rawURL == "" || (!strings.HasPrefix(rawURL, "http://") && !strings.HasPrefix(rawURL, "https://")) {
|
||||
@@ -80,33 +81,57 @@ func (h *DeliveryHandler) ImageProxy(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 1. 优先检查本地官方商品图片缓存
|
||||
for _, base := range []string{"data/uploads/products", "../data/uploads/products", "uploads/products"} {
|
||||
if strings.Contains(rawURL, "/products/") {
|
||||
parts := strings.Split(rawURL, "/products/")
|
||||
if len(parts) > 1 {
|
||||
sub := strings.Split(parts[1], "/")
|
||||
if len(sub) > 0 && sub[0] != "" {
|
||||
localPath := filepath.Join(base, sub[0]+".webp")
|
||||
if info, err := os.Stat(localPath); err == nil && info.Size() > 0 {
|
||||
c.Header("Cache-Control", "public, max-age=2592000, immutable")
|
||||
c.File(localPath)
|
||||
return
|
||||
}
|
||||
}
|
||||
sku := strings.TrimSpace(c.Query("sku"))
|
||||
publicDirs := []string{
|
||||
"../frontend/public/images/products",
|
||||
"frontend/public/images/products",
|
||||
"public/images/products",
|
||||
}
|
||||
uploadDirs := []string{"data/uploads/products", "../data/uploads/products", "uploads/products"}
|
||||
|
||||
tryLocal := func(dirs []string, name string) bool {
|
||||
if name == "" {
|
||||
return false
|
||||
}
|
||||
for _, base := range dirs {
|
||||
localPath := filepath.Join(base, name+".webp")
|
||||
if info, err := os.Stat(localPath); err == nil && info.Size() > 0 {
|
||||
c.Header("Cache-Control", "public, max-age=2592000, immutable")
|
||||
c.File(localPath)
|
||||
return true
|
||||
}
|
||||
}
|
||||
// 按 URL SHA256 哈希查本地缓存
|
||||
hash := sha256.Sum256([]byte(rawURL))
|
||||
hashPath := filepath.Join(base, hex.EncodeToString(hash[:])+".webp")
|
||||
if info, err := os.Stat(hashPath); err == nil && info.Size() > 0 {
|
||||
c.Header("Cache-Control", "public, max-age=2592000, immutable")
|
||||
c.File(hashPath)
|
||||
return
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// 2. 本地无缓存时请求远端
|
||||
// 1. 优先 SKU 命名的静态缓存
|
||||
if tryLocal(publicDirs, sku) {
|
||||
return
|
||||
}
|
||||
|
||||
// 2. 历史:URL 中的乐玩 UUID → 先查 public,再查 uploads
|
||||
var uuidName string
|
||||
if strings.Contains(rawURL, "/products/") {
|
||||
parts := strings.Split(rawURL, "/products/")
|
||||
if len(parts) > 1 {
|
||||
sub := strings.Split(parts[1], "/")
|
||||
if len(sub) > 0 && sub[0] != "" {
|
||||
uuidName = sub[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
if tryLocal(publicDirs, uuidName) || tryLocal(uploadDirs, uuidName) {
|
||||
return
|
||||
}
|
||||
|
||||
// 3. URL hash 缓存(uploads 运行时兜底)
|
||||
hash := sha256.Sum256([]byte(rawURL))
|
||||
hashName := hex.EncodeToString(hash[:])
|
||||
if tryLocal(uploadDirs, hashName) {
|
||||
return
|
||||
}
|
||||
|
||||
// 4. 本地无缓存时请求远端
|
||||
req, err := http.NewRequestWithContext(c.Request.Context(), http.MethodGet, rawURL, nil)
|
||||
if err != nil {
|
||||
response.BadRequest(c, "构建请求失败")
|
||||
@@ -131,20 +156,10 @@ func (h *DeliveryHandler) ImageProxy(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 3. 异步/同步回写到本地磁盘缓存
|
||||
// 5. 仅将未知外链写入 uploads 哈希缓存(不再按 UUID 污染正式目录)
|
||||
cacheDir := "data/uploads/products"
|
||||
_ = os.MkdirAll(cacheDir, 0755)
|
||||
if strings.Contains(rawURL, "/products/") {
|
||||
parts := strings.Split(rawURL, "/products/")
|
||||
if len(parts) > 1 {
|
||||
sub := strings.Split(parts[1], "/")
|
||||
if len(sub) > 0 && sub[0] != "" {
|
||||
_ = os.WriteFile(filepath.Join(cacheDir, sub[0]+".webp"), data, 0644)
|
||||
}
|
||||
}
|
||||
}
|
||||
hash := sha256.Sum256([]byte(rawURL))
|
||||
_ = os.WriteFile(filepath.Join(cacheDir, hex.EncodeToString(hash[:])+".webp"), data, 0644)
|
||||
_ = os.WriteFile(filepath.Join(cacheDir, hashName+".webp"), data, 0644)
|
||||
|
||||
contentType := resp.Header.Get("Content-Type")
|
||||
if contentType == "" {
|
||||
|
||||
@@ -56,8 +56,16 @@ func mapKeys(values map[string]struct{}) []string {
|
||||
|
||||
func (h *MerchantHandler) ListProducts(c *gin.Context) {
|
||||
page, size := pageParams(c)
|
||||
list, total, err := h.merchantSvc.ListMerchantProducts(middleware.GetMerchantID(c), page, size, false)
|
||||
list, total, err := h.merchantSvc.ListMerchantProductsWithOptions(middleware.GetMerchantID(c), page, size, service.ListMerchantProductsOptions{
|
||||
Status: strings.TrimSpace(c.Query("status")),
|
||||
Keyword: strings.TrimSpace(c.Query("keyword")),
|
||||
ExcludeLegacyLewanSKU: true, // LEWAN_* 镜像已废弃,列表永不返回
|
||||
})
|
||||
if err != nil {
|
||||
if err.Error() == "无效的商品状态" {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.ServerError(c, err.Error())
|
||||
return
|
||||
}
|
||||
@@ -86,6 +94,16 @@ func (h *MerchantHandler) UpdateProductStatus(c *gin.Context) {
|
||||
response.OK(c, nil)
|
||||
}
|
||||
|
||||
// DeleteProduct DELETE /api/merchant/products/:id — 仅已下架可删(软删除)。
|
||||
func (h *MerchantHandler) DeleteProduct(c *gin.Context) {
|
||||
id, _ := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err := h.merchantSvc.DeleteMerchantProduct(middleware.GetMerchantID(c), uint(id), middleware.GetUserID(c)); err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.OK(c, nil)
|
||||
}
|
||||
|
||||
func (h *MerchantHandler) ListOrders(c *gin.Context) {
|
||||
page, size := pageParams(c)
|
||||
orderSource := strings.TrimSpace(c.Query("order_source"))
|
||||
@@ -103,7 +121,7 @@ func (h *MerchantHandler) ListOrders(c *gin.Context) {
|
||||
|
||||
// ListManualOrderProducts returns active products for order creation without granting product-management access.
|
||||
func (h *MerchantHandler) ListManualOrderProducts(c *gin.Context) {
|
||||
list, _, err := h.merchantSvc.ListMerchantProducts(middleware.GetMerchantID(c), 1, 100, true)
|
||||
list, _, err := h.merchantSvc.ListMerchantProductsForClient(middleware.GetMerchantID(c), 1, 100)
|
||||
if err != nil {
|
||||
response.ServerError(c, err.Error())
|
||||
return
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"affiliate_dash/internal/middleware"
|
||||
"affiliate_dash/internal/model"
|
||||
@@ -30,7 +31,7 @@ func (h *OpenV1Handler) ListProducts(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
size, _ := strconv.Atoi(c.DefaultQuery("size", "20"))
|
||||
openlog.Info(c, "list_products start page=%d size=%d", page, size)
|
||||
list, total, err := h.merchantSvc.ListMerchantProducts(middleware.GetMerchantID(c), page, size, true)
|
||||
list, total, err := h.merchantSvc.ListMerchantProductsForClient(middleware.GetMerchantID(c), page, size)
|
||||
if err != nil {
|
||||
openlog.Warn(c, "list_products fail err=%v", err)
|
||||
response.ServerError(c, err.Error())
|
||||
@@ -71,6 +72,11 @@ func (h *OpenV1Handler) CreateOrder(c *gin.Context) {
|
||||
}
|
||||
data = decoded
|
||||
}
|
||||
if service.IsLegacyLewanMirrorSKU(req.SKU) {
|
||||
openlog.Warn(c, "create_order reject legacy lewan sku=%s", strings.TrimSpace(req.SKU))
|
||||
response.BadRequest(c, "请使用英文主商品 sku 下单,勿使用 LEWAN_ 镜像 sku")
|
||||
return
|
||||
}
|
||||
client := middleware.GetAPIClient(c)
|
||||
openlog.Info(c, "create_order start client_order_no=%s sku=%s quantity=%d buyer=%s",
|
||||
req.ClientOrderNo, req.SKU, req.Quantity, req.BuyerReference)
|
||||
|
||||
Reference in New Issue
Block a user