Files
affiliate_dash/backend/internal/service/provider_rock.go
T

518 lines
15 KiB
Go

package service
import (
"context"
"errors"
"fmt"
"strconv"
"strings"
"time"
"affiliate_dash/internal/model"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// RockProvider 实现 Rock 发货平台履约逻辑
type RockProvider struct {
rockService *RockService
}
func NewRockProvider(rockService *RockService) *RockProvider {
return &RockProvider{
rockService: rockService,
}
}
func (p *RockProvider) Name() string {
return ProviderRock
}
func (p *RockProvider) PrepareDelivery(ctx context.Context, order *model.FulfillmentOrder, openOrder *OpenOrderQuery) (*DeliveryOrderState, error) {
if p.rockService == nil {
return nil, errors.New("Rock 发货服务未初始化")
}
cfg, err := p.rockService.GetConfig(ctx)
if err != nil {
return nil, fmt.Errorf("读取 Rock 配置失败: %w", err)
}
// 1. 全局软熔断总开关检查 (Kill Switch)
if !cfg.AutoDeliveryEnabled {
return &DeliveryOrderState{
CanShip: false,
CannotShipReason: "当前该商品自动发货通道维护中,已转交人工客服/打手为您处理",
Provider: ProviderRock,
}, nil
}
// 2. 检查可用库存
// 注意:若当前订单已经分配并锁定了专属 Token (using 或 bound 状态),说明该订单库存已经预占成功,
// 绝不能因为池子中剩余的空闲库存为 0 而阻断该订单的发货与扫码提货流程!
hasAssignedToken := false
orderNo := ""
if order != nil && strings.TrimSpace(order.OrderNo) != "" {
orderNo = strings.TrimSpace(order.OrderNo)
} else if openOrder != nil && strings.TrimSpace(openOrder.OrderNo) != "" {
orderNo = strings.TrimSpace(openOrder.OrderNo)
}
if orderNo != "" {
incident, incidentErr := p.rockService.HasBlockingRockIncident(ctx, orderNo)
if incidentErr != nil {
return nil, incidentErr
}
if incident != nil {
return &DeliveryOrderState{
CanShip: false,
CannotShipReason: "该订单存在未处理发货异常,请先在 Rock 发货记录中处理",
Provider: ProviderRock,
}, nil
}
var assigned model.RockToken
findErr := p.rockService.db.WithContext(ctx).Unscoped().
Where("assigned_order_no = ? AND status IN ?", orderNo, []string{
model.RockStatusUsing,
model.RockStatusBound,
model.RockStatusMismatch,
model.RockStatusAbnormal,
}).
Order("id DESC").
First(&assigned).Error
if findErr == nil {
if assigned.Status == model.RockStatusAbnormal {
return &DeliveryOrderState{
CanShip: false,
CannotShipReason: rockOrderTokenLockedMessage,
Provider: ProviderRock,
}, nil
}
hasAssignedToken = true
}
}
if !hasAssignedToken {
stats, err := p.rockService.GetStats(ctx)
if err != nil {
return nil, fmt.Errorf("获取库存失败: %w", err)
}
if stats.AvailableStock <= 0 {
// 若开启了自动补货,立即触发一次补货检查
go p.rockService.CheckStockAndAutoPurchase(context.Background())
return &DeliveryOrderState{
CanShip: false,
CannotShipReason: "当前商品库存补充中,已通知管理员自动补货,请稍后刷新或联系客服",
Provider: ProviderRock,
}, nil
}
}
sku := ""
productName := "和平精英道具"
if order != nil {
sku = order.ProductSKU
if order.MerchantProduct != nil && order.MerchantProduct.DisplayName != "" {
productName = order.MerchantProduct.DisplayName
}
}
if openOrder != nil && openOrder.Product != nil && openOrder.Product.Name != "" {
productName = openOrder.Product.Name
}
deliveryProd := &DeliveryProduct{
Name: productName,
SKU: sku,
Game: "和平精英",
}
return &DeliveryOrderState{
CanShip: true,
Product: deliveryProd,
Provider: ProviderRock,
}, nil
}
func (p *RockProvider) Bind(ctx context.Context, order *model.FulfillmentOrder, gameAccount string) (*ProviderBindResult, error) {
if p.rockService == nil {
return nil, errors.New("Rock 发货服务未初始化")
}
trimmedUID := strings.TrimSpace(gameAccount)
if trimmedUID == "" {
return nil, errors.New("请先填写正确的游戏数字账号(UID)")
}
// 分配或锁定一个 Token 并获取上游活动链接
token, detail, err := p.rockService.AssignTokenForOrder(ctx, order.OrderNo, trimmedUID)
if err != nil {
return nil, err
}
patch := map[string]interface{}{
"provider": ProviderRock,
"rock_token_id": token.ID,
"rock_redeem_code": token.RedeemCode,
"expected_uid": trimmedUID,
}
bindURL := strings.TrimSpace(detail.BindURL)
qrURL := detail.QRURL
if qrURL == "" && bindURL != "" {
qrURL = qrCodeURL(bindURL)
}
isBound := token.Status == model.RockStatusBound
message := "请使用手机微信/QQ扫描上方二维码绑定游戏角色"
if isBound {
message = "账号绑定成功"
if token.ActualRoleName != "" {
patch["role_name"] = token.ActualRoleName
}
if token.ActualChannel != "" {
patch["game_channel"] = token.ActualChannel
}
if token.ActualRoleID != "" {
patch["actual_role_id"] = token.ActualRoleID
}
}
return &ProviderBindResult{
BindUUID: fmt.Sprintf("%d", token.ID),
BindURL: bindURL,
QRURL: qrURL,
Bound: isBound,
RoleName: token.ActualRoleName,
GameAccount: token.ActualRoleID,
GameChannel: token.ActualChannel,
ExpectedGameAccount: trimmedUID,
Message: message,
Provider: ProviderRock,
PatchResultData: patch,
}, nil
}
func (p *RockProvider) CheckBind(ctx context.Context, order *model.FulfillmentOrder, bindUUID string) (*ProviderBindResult, error) {
if p.rockService == nil {
return nil, errors.New("Rock 发货服务未初始化")
}
tokenIDUint, err := strconv.ParseUint(strings.TrimSpace(bindUUID), 10, 64)
if err != nil {
return nil, fmt.Errorf("无效的 bind_uuid: %s", bindUUID)
}
expectedUID := resultDataString(order.ResultData, "expected_uid")
if expectedUID == "" {
expectedUID = resultDataString(order.RequestData, "game_account")
}
if expectedUID == "" {
expectedUID = resultDataString(order.ResultData, "game_account")
}
res, err := p.rockService.CheckBindingAndVerifyUID(ctx, uint(tokenIDUint), order.OrderNo, expectedUID)
if err != nil {
return nil, err
}
patch := map[string]interface{}{
"provider": ProviderRock,
}
if res.RoleName != "" {
patch["role_name"] = res.RoleName
}
if res.Channel != "" {
patch["game_channel"] = res.Channel
}
if res.RoleID != "" {
patch["actual_role_id"] = res.RoleID
}
result := &ProviderBindResult{
BindUUID: bindUUID,
Bound: res.Bound,
RoleName: res.RoleName,
GameAccount: res.RoleID,
GameChannel: res.Channel,
ExpectedGameAccount: res.ExpectedUID,
Mismatch: res.Mismatch,
Message: res.Message,
Provider: ProviderRock,
PatchResultData: patch,
}
// 还在等扫码时,把同一提取码的最新活动链接交回去。下游只展示,不负责重取。
if !res.Bound && !res.Mismatch {
bindURL, qrURL := p.rockService.freshBindLink(ctx, uint(tokenIDUint), order.OrderNo)
result.BindURL = bindURL
result.QRURL = qrURL
}
return result, nil
}
func (p *RockProvider) Submit(ctx context.Context, order *model.FulfillmentOrder, gameAccount, bindUUID string) (*ProviderSubmitResult, error) {
if p.rockService == nil {
return nil, errors.New("Rock 发货服务未初始化")
}
sku := order.ProductSKU
expectedUID := strings.TrimSpace(gameAccount)
if expectedUID == "" {
expectedUID = resultDataString(order.ResultData, "expected_uid")
}
recorder := p.rockService.NewDeliveryRecorder(order.OrderNo, nil, "", "")
recorder.Internal(ctx, "submit_request", "started", "订单进入 Rock 发货流程", map[string]interface{}{
"order_no": order.OrderNo,
"product_sku": sku,
"expected_uid": expectedUID,
"bind_uuid": bindUUID,
"provider": ProviderRock,
})
if incident, incidentErr := p.rockService.HasBlockingRockIncident(ctx, order.OrderNo); incidentErr != nil {
return nil, incidentErr
} else if incident != nil {
return nil, fmt.Errorf("该订单存在未处理发货异常(%s),请人工处理后再重试", incident.Category)
}
result, err := p.rockService.RedeemTokenForOrder(ctx, order.OrderNo, sku, expectedUID, recorder)
if err != nil {
recorder.Internal(ctx, "order_update", "failed", err.Error(), map[string]interface{}{
"order_no": order.OrderNo,
})
return nil, err
}
token := result.Token
if token == nil {
return nil, errors.New("发货结果缺少 Token 信息")
}
roleName := token.ActualRoleName
if roleName == "" {
roleName = resultDataString(order.ResultData, "role_name")
}
channel := token.ActualChannel
if channel == "" {
channel = resultDataString(order.ResultData, "game_channel")
}
// 上游仍在异步处理:订单保持 delivering,不触发「已发货」回调。
if result.Pending {
recorder.Internal(ctx, "order_update", "pending", result.Message, map[string]interface{}{
"order_no": order.OrderNo,
"next_status": model.OrderStatusDelivering,
"token_id": token.ID,
"pending_since": token.RedeemRequestedAt,
})
return &ProviderSubmitResult{
NextStatus: model.OrderStatusDelivering,
Pending: true,
Message: "官方处理中,请稍后刷新查看结果(系统会自动确认发货状态)",
PatchResultData: map[string]interface{}{
"provider": ProviderRock,
"provider_order_stage": "redeem_pending",
"rock_redeem_pending": true,
"rock_token_id": token.ID,
"rock_redeem_code": token.RedeemCode,
"expected_uid": expectedUID,
"role_name": roleName,
"game_channel": channel,
"game_uid": expectedUID,
"skin": token.TargetSkin,
"currency": token.TargetCurrency,
"currency_quantity": token.TargetCurrencyQuantity,
},
}, nil
}
fulfillIDStr := ""
if token.UpstreamFulfillID > 0 {
fulfillIDStr = fmt.Sprintf("%d", token.UpstreamFulfillID)
}
skinName := token.TargetSkin
currencyName := token.TargetCurrency
if result.Response != nil {
if name := strings.TrimSpace(result.Response.Token.RedeemSkin); name != "" {
skinName = name
}
if name := strings.TrimSpace(result.Response.Token.RedeemCurrency); name != "" {
currencyName = name
}
}
if skinName == "" {
skinName = model.RockSkinBlackTshirt
}
if currencyName == "" {
currencyName = "幸运币"
}
patch := map[string]interface{}{
"provider": ProviderRock,
"provider_order_no": fulfillIDStr,
"fulfill_id": token.UpstreamFulfillID,
"rock_redeem_pending": false,
"rock_token_id": token.ID,
"rock_redeem_code": token.RedeemCode,
"skin": skinName,
"currency": currencyName,
"currency_quantity": token.TargetCurrencyQuantity,
"role_name": roleName,
"game_channel": channel,
"game_uid": expectedUID,
}
recorder.Internal(ctx, "order_update", "success", "订单发货完成", map[string]interface{}{
"order_no": order.OrderNo,
"next_status": model.OrderStatusDelivered,
"upstream_fulfill": fulfillIDStr,
})
return &ProviderSubmitResult{
ProviderOrderNo: fulfillIDStr,
NextStatus: model.OrderStatusDelivered,
Message: "发货成功!道具及附赠货币已发放至游戏,请登录游戏邮件查收",
GameAccount: map[string]interface{}{
"role_name": roleName,
"game_channel": channel,
"role_id": expectedUID,
},
PatchResultData: patch,
}, nil
}
func (p *RockProvider) SyncStatus(ctx context.Context, order *model.FulfillmentOrder) (*ProviderSyncResult, error) {
if p.rockService == nil {
return nil, errors.New("Rock 发货服务未初始化")
}
var token model.RockToken
err := p.rockService.db.WithContext(ctx).Unscoped().
Where("assigned_order_no = ?", order.OrderNo).
Order("id DESC").
First(&token).Error
if err != nil {
return &ProviderSyncResult{
Status: "",
}, nil
}
switch token.Status {
case model.RockStatusRedeemed:
return &ProviderSyncResult{
Status: "success",
ProviderOrderNo: fmt.Sprintf("%d", token.UpstreamFulfillID),
GameUID: token.ActualRoleID,
RoleName: token.ActualRoleName,
RoleArea: token.ActualChannel,
}, nil
case model.RockStatusMismatch:
return &ProviderSyncResult{
Status: "failed",
FailReason: token.MismatchReason,
}, nil
default:
return &ProviderSyncResult{
Status: "delivering",
}, nil
}
}
func (p *RockProvider) Cancel(ctx context.Context, order *model.FulfillmentOrder) error {
if p.rockService == nil {
return nil
}
var token model.RockToken
err := p.rockService.db.WithContext(ctx).
Where("assigned_order_no = ? AND status IN ?", order.OrderNo,
[]string{model.RockStatusUsing, model.RockStatusBound, model.RockStatusRedeemed}).
Order("id DESC").
First(&token).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil
}
if err != nil {
return err
}
switch token.Status {
case model.RockStatusUsing:
// 尚未扫码绑定,可以安全撤销本轮领号并回库存。
retryAsBound := false
err := p.rockService.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&token, token.ID).Error; err != nil {
return err
}
if token.Status != model.RockStatusUsing {
retryAsBound = token.Status == model.RockStatusBound ||
token.Status == model.RockStatusRedeemed
return nil
}
fromStatus := token.Status
now := time.Now()
if err := tx.Unscoped().Model(&token).Updates(map[string]interface{}{
"status": model.RockStatusAvailable,
"assigned_order_no": "",
"expected_uid": "",
"actual_role_name": "",
"actual_role_id": "",
"actual_channel": "",
"initial_bound_snapshot": "",
"mismatch_at": nil,
"mismatch_reason": "",
"bound_at": nil,
"using_at": nil,
"redeemed_at": nil,
"upstream_fulfill_id": 0,
"redeem_requested_at": nil,
"redeem_checked_at": nil,
"redeem_attempts": 0,
"redeem_next_retry_at": nil,
"redeem_last_message": "",
"redeem_last_error": "",
"redeem_timeout_warned_at": nil,
"restocked_at": nil, // 订单取消回滚不属于「释放回库存」,不产生优先权
"updated_at": now,
}).Error; err != nil {
return err
}
writeRockTokenStatusChange(tx, &token, fromStatus, model.RockStatusAvailable,
rockTokenSourceOrderCancel, order.OrderNo, false, nil)
return resolveOpenRockIncidents(tx, token.ID, "order_cancel", order.OrderNo, nil)
})
if err != nil {
return err
}
if retryAsBound {
return p.Cancel(ctx, order)
}
return nil
case model.RockStatusBound:
_, err = p.rockService.RecordRockIncident(
ctx,
&token,
order.OrderNo,
model.RockIncidentCategoryOrderCancelled,
"订单已取消,但 Token 已扫码绑定;禁止自动释放,请人工核对后释放或上报失效",
0,
"",
)
return err
case model.RockStatusRedeemed:
_, err = p.rockService.RecordRockIncident(
ctx,
&token,
order.OrderNo,
model.RockIncidentCategoryOrderCancelled,
"订单已取消,但上游已发货;需要人工核对退款与履约",
0,
"",
)
return err
default:
return nil
}
}