feat: 优化发货交付页面与凭据生成,支持一键复制结果图片及官方商品图片本地缓存

- 交付界面重构:隐藏内部订单号、流水号与发货渠道,展示聚焦于买家游戏UID、角色名与交付时间
- 发货结果凭据生成:采用 Canvas 生成高清发货存证图,优化存证说明文案与自适应排版
- 一键复制图片:支持直接将结果凭证图片写入系统剪贴板,方便客服在聊天窗口快捷粘贴
- 官方图片本地化缓存:全量下载乐玩商品官方主图并建立本地持久化路由,规避外部源站跨域及破损问题
- 修复乐玩渠道角色UID提取与履约状态幂等回填逻辑
This commit is contained in:
yml2213
2026-09-14 17:19:12 +08:00
parent f7f48d0feb
commit 5f642eb7a6
34 changed files with 1428 additions and 66 deletions
+89
View File
@@ -1,8 +1,15 @@
package handler
import (
"crypto/sha256"
"encoding/hex"
"io"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"affiliate_dash/internal/pkg/response"
"affiliate_dash/internal/service"
@@ -65,6 +72,88 @@ func (h *DeliveryHandler) Submit(c *gin.Context) {
response.OK(c, result)
}
// ImageProxy 代理图片并支持本地持久化缓存,杜绝跨域污染和远端加载失败。
func (h *DeliveryHandler) ImageProxy(c *gin.Context) {
rawURL := strings.TrimSpace(c.Query("url"))
if rawURL == "" || (!strings.HasPrefix(rawURL, "http://") && !strings.HasPrefix(rawURL, "https://")) {
response.BadRequest(c, "无效的图片地址")
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
}
}
}
}
// 按 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
}
}
// 2. 本地无缓存时请求远端
req, err := http.NewRequestWithContext(c.Request.Context(), http.MethodGet, rawURL, nil)
if err != nil {
response.BadRequest(c, "构建请求失败")
return
}
client := &http.Client{Timeout: 15 * time.Second}
resp, err := client.Do(req)
if err != nil {
response.Fail(c, http.StatusBadGateway, http.StatusBadGateway, "图片下载失败")
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
response.Fail(c, resp.StatusCode, resp.StatusCode, "远端图片响应异常")
return
}
data, err := io.ReadAll(resp.Body)
if err != nil {
response.Fail(c, http.StatusBadGateway, http.StatusBadGateway, "读取图片内容失败")
return
}
// 3. 异步/同步回写到本地磁盘缓存
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)
contentType := resp.Header.Get("Content-Type")
if contentType == "" {
contentType = "image/webp"
}
c.Header("Cache-Control", "public, max-age=2592000, immutable")
c.Data(http.StatusOK, contentType, data)
}
func parseDeliveryAuth(c *gin.Context) service.DeliveryLinkAuth {
exp := int64(0)
if value := strings.TrimSpace(c.Query("exp")); value != "" {
@@ -118,3 +118,38 @@ func (h *PlatformLewanHandler) SyncLewanProduct(c *gin.Context) {
}
response.WithStatus(c, http.StatusCreated, item)
}
type batchSyncLewanReq struct {
Products []syncLewanProductReq `json:"products" binding:"required,min=1"`
}
// BatchSyncLewanProducts POST /api/platform/lewan/batch-sync-catalog
// 批量同步/添加乐玩商品到自营商品目录
func (h *PlatformLewanHandler) BatchSyncLewanProducts(c *gin.Context) {
var req batchSyncLewanReq
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "参数错误:products 列表不能为空")
return
}
actorUserID := middleware.GetUserID(c)
inputs := make([]service.SyncLewanProductInput, len(req.Products))
for i, p := range req.Products {
inputs[i] = service.SyncLewanProductInput{
LewanProductID: p.LewanProductID,
Name: p.Name,
Category: p.Category,
SKU: p.SKU,
Price: p.Price,
DeliveryType: p.DeliveryType,
}
}
count, err := h.merchantSvc.BatchSyncLewanProductsToCatalog(inputs, actorUserID)
if err != nil {
response.BadRequest(c, err.Error())
return
}
response.OK(c, gin.H{"synced_count": count})
}