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" "github.com/gin-gonic/gin" ) // DeliveryHandler 提供用户侧公开发货页面使用的接口。 type DeliveryHandler struct { deliverySvc *service.DeliveryService } func NewDeliveryHandler(deliverySvc *service.DeliveryService) *DeliveryHandler { return &DeliveryHandler{deliverySvc: deliverySvc} } func (h *DeliveryHandler) GetOrder(c *gin.Context) { order, err := h.deliverySvc.GetOrder(c.Param("order_no"), parseDeliveryAuth(c)) if err != nil { writeDeliveryError(c, err) return } response.OK(c, order) } type deliveryBindReq struct { GameAccount string `json:"game_account" binding:"required"` } func (h *DeliveryHandler) Bind(c *gin.Context) { var req deliveryBindReq if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, "请输入玩家编号") return } result, err := h.deliverySvc.Bind(c.Param("order_no"), req.GameAccount, parseDeliveryAuth(c)) if err != nil { writeDeliveryError(c, err) return } response.OK(c, result) } func (h *DeliveryHandler) GetBindResult(c *gin.Context) { bindUUID := strings.TrimSpace(c.Query("bind_uuid")) if bindUUID == "" { response.BadRequest(c, "bind_uuid 不能为空") return } result, err := h.deliverySvc.GetBindResultLinked(c.Param("order_no"), bindUUID, parseDeliveryAuth(c)) if err != nil { writeDeliveryError(c, err) return } response.OK(c, result) } type deliverySubmitReq struct { GameAccount string `json:"game_account" binding:"required"` BindUUID string `json:"bind_uuid" binding:"required"` } func (h *DeliveryHandler) Submit(c *gin.Context) { var req deliverySubmitReq if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, "玩家编号和绑定凭证不能为空") return } result, err := h.deliverySvc.Submit(c.Param("order_no"), req.GameAccount, req.BindUUID, parseDeliveryAuth(c)) if err != nil { writeDeliveryError(c, err) return } response.OK(c, result) } // 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://")) { response.BadRequest(c, "无效的图片地址") 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 } } return false } // 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, "构建请求失败") 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 } // 5. 仅将未知外链写入 uploads 哈希缓存(不再按 UUID 污染正式目录) cacheDir := "data/uploads/products" _ = os.MkdirAll(cacheDir, 0755) _ = os.WriteFile(filepath.Join(cacheDir, hashName+".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 != "" { if parsed, err := strconv.ParseInt(value, 10, 64); err == nil { exp = parsed } } return service.DeliveryLinkAuth{ Exp: exp, Sign: strings.TrimSpace(c.Query("sign")), } } func writeDeliveryError(c *gin.Context, err error) { type statusCoder interface { HTTPStatus() int } if sc, ok := err.(statusCoder); ok { status := sc.HTTPStatus() response.Fail(c, status, status, strings.TrimSpace(err.Error())) return } msg := strings.TrimSpace(err.Error()) if msg == "订单不存在" { response.NotFound(c, msg) return } if msg == "链接参数缺失" || msg == "发货链接未生成" || msg == "发货链接已失效" || msg == "发货链接已过期" || msg == "发货链接签名无效" { response.Forbidden(c, msg) return } response.BadRequest(c, msg) }