- 交付界面重构:隐藏内部订单号、流水号与发货渠道,展示聚焦于买家游戏UID、角色名与交付时间 - 发货结果凭据生成:采用 Canvas 生成高清发货存证图,优化存证说明文案与自适应排版 - 一键复制图片:支持直接将结果凭证图片写入系统剪贴板,方便客服在聊天窗口快捷粘贴 - 官方图片本地化缓存:全量下载乐玩商品官方主图并建立本地持久化路由,规避外部源站跨域及破损问题 - 修复乐玩渠道角色UID提取与履约状态幂等回填逻辑
190 lines
5.3 KiB
Go
190 lines
5.3 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)
|
|
}
|
|
|
|
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 代理图片并支持本地持久化缓存,杜绝跨域污染和远端加载失败。
|
|
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 != "" {
|
|
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)
|
|
}
|