Files
affiliate_dash/backend/internal/handler/delivery.go
T
yml2213andCursor df1a170d44 refactor(catalog): 废弃 LEWAN_ 镜像,统一英文 SKU 与积分价,商品图按 SKU 缓存
对外目录只保留英文主 sku;迁移硬删镜像并校准 1元=100积分;发货页/凭证优先使用 local {sku}.webp。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-14 20:00:26 +08:00

205 lines
5.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
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)
}