Files
affiliate_dash/backend/internal/service/provider_rock.go
T
yml2213 bf3f3a3626 feat(delivery-voucher): 优化发货凭据为一键复制深海蓝高清图并补齐商品本地图片
1. 管理后台 Rock 表格优化:将原「官方发货凭证(FulfillId)」列升级为「发货结果图片」列,提供一键「复制结果图」与「高清预览」Modal,点击即可自动生成凭据大图并直接写入剪贴板(支持在微信/QQ客服聊天框中直接 Ctrl+V 粘贴图片);
2. 商品图片本地缓存与展示修复:从官方 HAR 提取黑色 ROCK 长袖 T 恤与蓝色运动服上衣高清立绘(rock_black_tshirt.webp / rock_blue_sportswear.webp),优化 productImage 自动识别与 Delivery 提货页图片自适应居中,彻底消除灰色占位块;
3. 标杆级发货凭据绘制重构:参考腾讯官方电竞风格设计,重构 640x960 视网膜双倍高清 Canvas 凭据绘制(深海蓝微光渐变背景、顶部微光加粗「兑换成功」、正圆形双层光环立绘、详细角色区服参数对齐、底部翡翠绿发光胶囊按钮),全方位超越 0666 官方粗糙原图;
4. 履约结果字段持久化:后端发货成功后同步持久化角色名、游戏大区与 UID 至 fulfillment_orders 结果报文,确保提货页区服与角色信息完整呈现。
2026-10-05 18:52:41 +08:00

324 lines
9.1 KiB
Go

package service
import (
"context"
"errors"
"fmt"
"strconv"
"strings"
"affiliate_dash/internal/model"
)
// 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 != "" {
var assignedCount int64
_ = p.rockService.db.WithContext(ctx).Model(&model.RockToken{}).
Where("assigned_order_no = ? AND status IN (?, ?)", orderNo, model.RockStatusUsing, model.RockStatusBound).
Count(&assignedCount).Error
if assignedCount > 0 {
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)
}
return &ProviderBindResult{
BindUUID: fmt.Sprintf("%d", token.ID),
BindURL: bindURL,
QRURL: qrURL,
Bound: false,
ExpectedGameAccount: trimmedUID,
Message: "请使用手机微信/QQ扫描上方二维码绑定游戏角色",
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), 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
}
return &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,
}, 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")
}
redeemResp, err := p.rockService.RedeemTokenForOrder(ctx, order.OrderNo, sku, expectedUID)
if err != nil {
return nil, err
}
var token model.RockToken
_ = p.rockService.db.WithContext(ctx).Where("assigned_order_no = ?", order.OrderNo).Order("id DESC").First(&token).Error
fulfillIDStr := ""
if token.UpstreamFulfillID > 0 {
fulfillIDStr = fmt.Sprintf("%d", token.UpstreamFulfillID)
}
skinName := redeemResp.Token.RedeemSkin
if skinName == "" {
skinName = token.TargetSkin
}
currencyName := redeemResp.Token.RedeemCurrency
if currencyName == "" {
currencyName = token.TargetCurrency
}
roleName := token.ActualRoleName
if roleName == "" {
roleName = resultDataString(order.ResultData, "role_name")
}
channel := token.ActualChannel
if channel == "" {
channel = resultDataString(order.ResultData, "game_channel")
}
patch := map[string]interface{}{
"provider": ProviderRock,
"provider_order_no": fulfillIDStr,
"fulfill_id": token.UpstreamFulfillID,
"skin": skinName,
"currency": currencyName,
"currency_quantity": token.TargetCurrencyQuantity,
"role_name": roleName,
"game_channel": channel,
"game_uid": expectedUID,
}
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).
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
}
// 若订单尚未扫码绑定成功,将处于 using 状态的 Token 安全重置回 available
var token model.RockToken
err := p.rockService.db.WithContext(ctx).
Where("assigned_order_no = ? AND status = ?", order.OrderNo, model.RockStatusUsing).
First(&token).Error
if err == nil {
_ = p.rockService.db.WithContext(ctx).Model(&token).Updates(map[string]interface{}{
"status": model.RockStatusAvailable,
"assigned_order_no": "",
"expected_uid": "",
}).Error
}
return nil
}