219 lines
5.9 KiB
Go
219 lines
5.9 KiB
Go
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)
|
||
}
|