纯文件级移动,无行为变更: - delivery.go 常量、DeliveryService 结构、构造与依赖注入 - delivery_types.go 对外 DTO 与 deliveryHTTPError - delivery_provider.go 渠道取消/同步与 result_data 维护 - delivery_query.go 订单查询与发货状态构建 - delivery_bind.go 绑定与绑定结果查询 - delivery_submit.go 提交发货与提交阶段状态机 - deliveryGoodID 并入 delivery_helpers.go,链接管理并入 delivery_link.go 校验:拆分前后 55 个 func/type/const 声明完全一致(无缺失、无多出、无重复)。
219 lines
7.9 KiB
Go
219 lines
7.9 KiB
Go
package service
|
|
|
|
import (
|
|
"crypto/hmac"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"affiliate_dash/internal/model"
|
|
"affiliate_dash/internal/pkg/timeutil"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
func (s *DeliveryService) authorizeDeliveryLink(order *model.FulfillmentOrder, auth DeliveryLinkAuth) error {
|
|
if order == nil {
|
|
return newDeliveryHTTPError(http.StatusNotFound, "订单不存在")
|
|
}
|
|
if strings.TrimSpace(auth.Sign) == "" {
|
|
return newDeliveryHTTPError(http.StatusForbidden, "链接参数缺失")
|
|
}
|
|
if order.DeliveryLinkRevokedAt != nil {
|
|
return newDeliveryHTTPError(http.StatusForbidden, "发货链接已作废")
|
|
}
|
|
// 订单已取消后链接立即失效,防止已退款订单仍通过链接查看或继续发货。
|
|
if normalizeOrderStatus(order) == model.OrderStatusCancelled {
|
|
return newDeliveryHTTPError(http.StatusForbidden, "订单已取消,发货链接不可用")
|
|
}
|
|
// 从未生成过链接(兼容历史数据:本次改动前生成的有时效链接没有 generated_at 记录)。
|
|
if order.DeliveryLinkGeneratedAt == nil && order.DeliveryLinkExpiresAt == nil {
|
|
return newDeliveryHTTPError(http.StatusForbidden, "发货链接未生成")
|
|
}
|
|
// 永久有效链接(expires_at 为 NULL):仅校验签名(exp 固定为 0),不检查过期。
|
|
if order.DeliveryLinkExpiresAt == nil {
|
|
expected := s.signDeliveryLink(order.OrderNo, auth.Exp)
|
|
if !hmac.Equal([]byte(strings.ToLower(strings.TrimSpace(auth.Sign))), []byte(expected)) {
|
|
return newDeliveryHTTPError(http.StatusForbidden, "发货链接签名无效")
|
|
}
|
|
return nil
|
|
}
|
|
expiresAt := order.DeliveryLinkExpiresAt.UTC().Truncate(time.Second)
|
|
linkExpires := time.Unix(auth.Exp, 0).UTC()
|
|
if !linkExpires.Equal(expiresAt) {
|
|
return newDeliveryHTTPError(http.StatusForbidden, "发货链接已失效")
|
|
}
|
|
if time.Now().UTC().After(linkExpires) {
|
|
return newDeliveryHTTPError(http.StatusForbidden, "发货链接已过期")
|
|
}
|
|
expected := s.signDeliveryLink(order.OrderNo, auth.Exp)
|
|
if !hmac.Equal([]byte(strings.ToLower(strings.TrimSpace(auth.Sign))), []byte(expected)) {
|
|
return newDeliveryHTTPError(http.StatusForbidden, "发货链接签名无效")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// buildDeliveryLinkResult 组装链接结果;expiresAt 为 nil 表示永久有效(exp 固定为 0)。
|
|
func (s *DeliveryService) buildDeliveryLinkResult(orderNo string, expiresAt *time.Time, requestBaseURL string) *DeliveryLinkResult {
|
|
exp := int64(0)
|
|
if expiresAt != nil {
|
|
exp = expiresAt.UTC().Unix()
|
|
}
|
|
sign := s.signDeliveryLink(orderNo, exp)
|
|
return &DeliveryLinkResult{
|
|
OrderNo: orderNo,
|
|
DeliveryURL: s.buildDeliveryURL(orderNo, exp, sign, requestBaseURL),
|
|
ExpiresAt: expiresAt,
|
|
Exp: exp,
|
|
Sign: sign,
|
|
}
|
|
}
|
|
|
|
func (s *DeliveryService) buildDeliveryURL(orderNo string, exp int64, sign, requestBaseURL string) string {
|
|
path := fmt.Sprintf("/delivery/%s/%s?exp=%d&sign=%s", url.PathEscape(s.channel), url.PathEscape(orderNo), exp, url.QueryEscape(sign))
|
|
base := strings.TrimRight(s.linkBaseURL, "/")
|
|
if base == "" {
|
|
base = strings.TrimRight(requestBaseURL, "/")
|
|
}
|
|
if base == "" {
|
|
return path
|
|
}
|
|
return base + path
|
|
}
|
|
|
|
func (s *DeliveryService) signDeliveryLink(orderNo string, exp int64) string {
|
|
mac := hmac.New(sha256.New, []byte(s.linkSecret))
|
|
_, _ = mac.Write([]byte(orderNo + "|" + strconv.FormatInt(exp, 10)))
|
|
return hex.EncodeToString(mac.Sum(nil))
|
|
}
|
|
|
|
func (s *DeliveryService) GetOrCreateDeliveryLink(merchantID uint, orderNo, requestBaseURL string) (*DeliveryLinkResult, error) {
|
|
orderNo = strings.TrimSpace(orderNo)
|
|
if orderNo == "" {
|
|
return nil, errors.New("订单号不能为空")
|
|
}
|
|
order, err := s.fulfillment.GetOrder(merchantID, orderNo)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// 已取消订单即使存在未过期链接也不再发放(买家侧授权同样拦截已取消订单)。
|
|
if normalizeOrderStatus(order) == model.OrderStatusCancelled {
|
|
return nil, newDeliveryHTTPError(http.StatusBadRequest, "订单已取消,无法获取发货链接")
|
|
}
|
|
now := time.Now().UTC()
|
|
if order.DeliveryLinkRevokedAt != nil {
|
|
return nil, newDeliveryHTTPError(http.StatusForbidden, "发货链接已作废")
|
|
}
|
|
// 复用未过期链接:已交付/发货中订单也允许查看兑换结果或进度,仅停止发放新链接。
|
|
if order.DeliveryLinkExpiresAt != nil && order.DeliveryLinkExpiresAt.After(now) {
|
|
expiresAt := order.DeliveryLinkExpiresAt.UTC().Truncate(time.Second)
|
|
return s.buildDeliveryLinkResult(order.OrderNo, &expiresAt, requestBaseURL), nil
|
|
}
|
|
// 永久有效链接(已生成、未作废、无有效期)直接复用,不重复生成。
|
|
if order.DeliveryLinkGeneratedAt != nil && order.DeliveryLinkExpiresAt == nil {
|
|
return s.buildDeliveryLinkResult(order.OrderNo, nil, requestBaseURL), nil
|
|
}
|
|
// 仅待发货(含可重试的发货失败)订单能生成或续期新链接。
|
|
canShip, reason := CanFulfill(order)
|
|
if !canShip {
|
|
if reason == "" {
|
|
reason = "订单暂不可发货"
|
|
}
|
|
return nil, newDeliveryHTTPError(http.StatusBadRequest, reason)
|
|
}
|
|
var expiresAt *time.Time
|
|
if s.linkTTL > 0 {
|
|
value := now.Add(s.linkTTL).UTC().Truncate(time.Second)
|
|
expiresAt = &value
|
|
}
|
|
if err := s.fulfillment.db.Model(&model.FulfillmentOrder{}).
|
|
Where("id = ?", order.ID).
|
|
Updates(map[string]interface{}{
|
|
"delivery_link_expires_at": expiresAt,
|
|
"delivery_link_revoked_at": nil,
|
|
"delivery_link_generated_at": now,
|
|
}).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return s.buildDeliveryLinkResult(order.OrderNo, expiresAt, requestBaseURL), nil
|
|
}
|
|
|
|
func (s *DeliveryService) RevokeDeliveryLink(merchantID, actorUserID uint, orderNo string) error {
|
|
orderNo = strings.TrimSpace(orderNo)
|
|
if orderNo == "" {
|
|
return errors.New("订单号不能为空")
|
|
}
|
|
order, err := s.fulfillment.GetOrder(merchantID, orderNo)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
now := time.Now().UTC().Truncate(time.Second)
|
|
return s.fulfillment.db.Transaction(func(tx *gorm.DB) error {
|
|
if err := tx.Model(&model.FulfillmentOrder{}).
|
|
Where("id = ?", order.ID).
|
|
Updates(map[string]interface{}{
|
|
"delivery_link_revoked_at": now,
|
|
}).Error; err != nil {
|
|
return err
|
|
}
|
|
return writeAudit(tx, &merchantID, optionalUint(actorUserID), nil, "delivery_link.revoke", "fulfillment_order", order.OrderNo, map[string]interface{}{
|
|
"revoked_at": timeutil.FormatAPITime(now),
|
|
})
|
|
})
|
|
}
|
|
|
|
func (s *DeliveryService) RestoreDeliveryLink(merchantID, actorUserID uint, orderNo, requestBaseURL string) (*DeliveryLinkResult, error) {
|
|
orderNo = strings.TrimSpace(orderNo)
|
|
if orderNo == "" {
|
|
return nil, errors.New("订单号不能为空")
|
|
}
|
|
order, err := s.fulfillment.GetOrder(merchantID, orderNo)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
canShip, reason := CanFulfill(order)
|
|
if !canShip {
|
|
if reason == "" {
|
|
reason = "订单暂不可发货"
|
|
}
|
|
return nil, newDeliveryHTTPError(http.StatusBadRequest, reason)
|
|
}
|
|
now := time.Now().UTC()
|
|
var expiresAt *time.Time
|
|
if s.linkTTL > 0 {
|
|
value := now.Add(s.linkTTL).Truncate(time.Second)
|
|
if order.DeliveryLinkExpiresAt != nil && value.Equal(order.DeliveryLinkExpiresAt.UTC().Truncate(time.Second)) {
|
|
value = value.Add(time.Second)
|
|
}
|
|
expiresAt = &value
|
|
}
|
|
if err := s.fulfillment.db.Transaction(func(tx *gorm.DB) error {
|
|
if err := tx.Model(&model.FulfillmentOrder{}).
|
|
Where("id = ?", order.ID).
|
|
Updates(map[string]interface{}{
|
|
"delivery_link_expires_at": expiresAt,
|
|
"delivery_link_revoked_at": nil,
|
|
"delivery_link_generated_at": now,
|
|
}).Error; err != nil {
|
|
return err
|
|
}
|
|
metadata := map[string]interface{}{
|
|
"generated_at": timeutil.FormatAPITime(now),
|
|
}
|
|
if expiresAt != nil {
|
|
metadata["expires_at"] = timeutil.FormatAPITime(*expiresAt)
|
|
} else {
|
|
metadata["permanent"] = true
|
|
}
|
|
return writeAudit(tx, &merchantID, optionalUint(actorUserID), nil, "delivery_link.restore", "fulfillment_order", order.OrderNo, metadata)
|
|
}); err != nil {
|
|
return nil, err
|
|
}
|
|
return s.buildDeliveryLinkResult(order.OrderNo, expiresAt, requestBaseURL), nil
|
|
}
|