Commit b989e7b5 authored by 刘小敏's avatar 刘小敏

feat(xhs): 对齐小红书种草直达API并完善转化回传

parent 31a2b4a6
......@@ -38,10 +38,10 @@ class XhsAd extends Common
custom_log('[xhs.track] 收到追踪请求, user_id=' . $userId . ', params=' . json_encode($params, JSON_UNESCAPED_UNICODE), $userId, 'xhs_ad');
if (empty($userId)) {
custom_log('[xhs.track] user_id 为空,拒绝记录', $userId, 'xhs_ad', 'warning');
$this->error('用户未登录');
}
// if (empty($userId)) {
// custom_log('[xhs.track] user_id 为空,拒绝记录', $userId, 'xhs_ad', 'warning');
// $this->error('用户未登录');
// }
if (empty($params['click_id'])) {
custom_log('[xhs.track] click_id 为空,拒绝记录', $userId, 'xhs_ad', 'warning');
......@@ -55,6 +55,7 @@ class XhsAd extends Common
'request_id' => $params['request_id'] ?? '',
'event_type' => $params['event_type'] ?? '',
'landing_page' => $params['landing_page'] ?? '',
'platform' => 'BEEVERA WechatMiniProgram',
]);
if (!$tracking) {
......@@ -148,6 +149,7 @@ class XhsAd extends Common
'value' => $value,
'order_id' => $orderId,
'conversion_time' => $now,
'platform' => 'WechatMiniProgram',
];
\think\Queue::push('addons\shopro\job\XhsConversionReport', $jobData, 'shopro');
......
......@@ -19,13 +19,23 @@ use addons\shopro\service\xhs\ConversionService;
* - value float 转化金额
* - order_id string 订单号
* - conversion_time int 转化发生时间(秒级时间戳)
* - platform string 平台来源,默认 WechatMiniProgram
*
* 重试规则(对齐小红书官方要求):
* - 最多重试 30 次
* - 每次重试间隔 60 秒(1 分钟)
*/
class XhsConversionReport extends BaseJob
{
/**
* 任务失败重试次数上限
* 任务失败重试次数上限(官方要求 30 次)
*/
const MAX_ATTEMPTS = 3;
const MAX_ATTEMPTS = 30;
/**
* 重试间隔(秒),官方要求每次间隔 1 分钟
*/
const RETRY_DELAY = 60;
/**
* 执行队列任务
......@@ -64,12 +74,9 @@ class XhsConversionReport extends BaseJob
return false;
}
// 阶梯延迟重试:第1次 30s,第2次 120s,第3次 300s
$delayMap = [1 => 30, 2 => 120, 3 => 300];
$delay = $delayMap[$attempts + 1] ?? 60;
custom_log('[xhs.job] 回传失败,第' . $attempts . '次尝试,' . $delay . '秒后重试, log_id=' . $logId, $userId, 'xhs_ad', 'warning');
$job->release($delay);
// 固定 60 秒后重试(对齐官方要求)
custom_log('[xhs.job] 回传失败,第' . $attempts . '次尝试,' . self::RETRY_DELAY . '秒后重试, log_id=' . $logId, $userId, 'xhs_ad', 'warning');
$job->release(self::RETRY_DELAY);
return false;
}
} catch (\Exception $e) {
......@@ -82,8 +89,8 @@ class XhsConversionReport extends BaseJob
return false;
}
// 异常时 60s 后重试
$job->release(60);
// 异常时 60后重试
$job->release(self::RETRY_DELAY);
return false;
}
}
......
......@@ -17,12 +17,14 @@ class ConversionService
{
// API 基础地址(以小红书开放平台官方文档为准)
const API_BASE = 'https://ad-market.xiaohongshu.com/open/api';
// const API_BASE = 'https://adapi.xiaohongshu.com';
// Token 接口
const TOKEN_URL = '/oauth/token';
// 转化回传接口(具体路径以开放平台文档为准)
const CONVERSION_URL = '/conversion/upload';
// const CONVERSION_URL = '/api/open/conversion';
// Token 缓存 key 前缀
const TOKEN_CACHE_KEY = 'shopro:xhs:access_token';
......@@ -96,14 +98,16 @@ class ConversionService
}
/**
* 回传转化事件到小红书
* 回传转化事件到小红书(对齐官方「种草直达」API 文档)
* 官方文档: https://adapi.xiaohongshu.com/api/open/conversion
*
* @param array $data 转化数据
* - click_id string 广告点击 ID
* - conversion_type string 转化类型
* - click_id string 广告点击 ID(必填)
* - conversion_type string 转化类型(必填)
* - value float 转化金额
* - order_id string 订单号
* - order_id string 订单号(用于查询商品上下文)
* - conversion_time int 转化发生时间(秒级时间戳)
* - platform string 平台来源,默认 WechatMiniProgram
* @return array ['success' => bool, 'response' => mixed]
*/
public function reportConversion($data)
......@@ -115,31 +119,42 @@ class ConversionService
return ['success' => false, 'response' => 'click_id 为空'];
}
// 获取 app_id 和 access_token
$appId = $this->getConfig('APP_ID', '');
try {
$accessToken = $this->getAccessToken();
} catch (\Exception $e) {
return ['success' => false, 'response' => '获取 Token 失败: ' . $e->getMessage()];
}
// 转换事件类型为小红书平台编码(具体编码以开放平台文档为准)
if (empty($appId)) {
return ['success' => false, 'response' => 'app_id 未配置'];
}
// 转换事件类型为小红书平台编码
// 1=商品成交, 122=微信加好友, 123=注册, 124=微信小程序访问
$eventType = $this->mapEventType($conversionType);
// 构建 context.product 商品上下文(仅商品成交事件需要)
$context = $this->buildContext($data, $eventType);
$url = self::API_BASE . self::CONVERSION_URL;
// 对齐官方 API 请求体格式: app_id、access_token 放在 body 中,不放在 Header
$body = [
'click_id' => $clickId,
'event_type' => $eventType,
'event_time' => intval($data['conversion_time'] ?? 0),
'conversion_value' => floatval($data['value'] ?? 0),
'app_id' => $appId,
'access_token' => $accessToken,
'event_type' => strval($eventType),
'timestamp' => intval(microtime(true) * 1000), // 毫秒级时间戳
'click_id' => $clickId,
'platform' => $data['platform'] ?? '爱岛珠宝微信小程序',
];
// 有订单号时附加
if (!empty($data['order_id'])) {
$body['order_id'] = $data['order_id'];
if (!empty($context)) {
$body['context'] = $context;
}
$headers = [
'Authorization: Bearer ' . $accessToken,
'Content-Type: application/json',
];
......@@ -158,6 +173,108 @@ class ConversionService
}
/**
* 构建转化上下文(context.product)
* 对于商品成交(event_type=1),自动查询订单获取商品信息
*
* @param array $data 转化数据
* @param int $eventType 小红书事件类型编码
* @return array
*/
private function buildContext($data, $eventType)
{
$context = [];
// 只有商品成交事件(event_type=1)需要商品上下文
if ($eventType != 1) {
return $context;
}
$orderId = $data['order_id'] ?? '';
if (empty($orderId)) {
return $context;
}
try {
$orderModel = \app\admin\model\shopro\order\Order::where('id', $orderId)->find();
if (!$orderModel) {
custom_log('[xhs.context] 未找到订单, order_sn=' . $orderId, 0, 'xhs_ad', 'warning');
return $context;
}
$items = \app\admin\model\shopro\order\OrderItem::where('order_id', $orderId)->select();
$orderCount = 0;
$productId = '';
$productName = '';
$productPrice = 0;
$productImg = '';
foreach ($items as $item) {
$orderCount += intval($item->goods_num ?? 1);
if (empty($productId)) {
$productId = strval($item->goods_id ?? '');
$productName = $item->goods_title ?? '';
$productPrice = floatval($item->goods_price ?? 0);
$productImg = $item->goods_image ?? '';
}
}
$context['product'] = [
'product_id' => $productId,
'product_name' => $productName,
'product_price' => $productPrice,
'product_category' => $this->getCategoryName($orderModel),
'img_url' => $productImg,
'shop_name' => 'BEEVERA',
'order_id' => $orderId,
'order_count' => $orderCount,
'pay_amount' => floatval($data['value'] ?? ($orderModel->pay_fee ?? 0)),
];
custom_log('[xhs.context] 商品上下文构建成功, order_sn=' . $orderId, 0, 'xhs_ad');
} catch (\Exception $e) {
custom_log('[xhs.context] 获取商品上下文异常: ' . $e->getMessage(), 0, 'xhs_ad', 'error');
}
return $context;
}
/**
* 获取订单商品分类名称
*
* @param \app\admin\model\shopro\order\Order $order
* @return string
*/
private function getCategoryName($order)
{
try {
$items = $order->items;
if (!empty($items)) {
$firstItem = $items[0];
$goodsId = $firstItem->goods_id ?? 0;
if ($goodsId) {
$goods = \app\admin\model\shopro\goods\Goods::find($goodsId);
if ($goods && !empty($goods->category_ids)) {
$categoryIds = is_string($goods->category_ids)
? json_decode($goods->category_ids, true)
: $goods->category_ids;
if (!empty($categoryIds) && is_array($categoryIds)) {
$categoryId = $categoryIds[0] ?? 0;
if ($categoryId) {
$category = \app\admin\model\shopro\category\Category::find($categoryId);
return $category ? $category->name : '';
}
}
}
}
}
} catch (\Exception $e) {
// 分类查询失败不影响主流程
}
return '';
}
/**
* 处理转化回传(含重试逻辑)
*
* @param array $jobData 队列数据
......@@ -226,21 +343,26 @@ class ConversionService
/**
* 转化类型映射:内部类型 → 小红书事件编码
* ⚠️ 具体编码以小红书开放平台文档为准
* 官方事件枚举:
* 1 = 商品成交(支持联调/报表展示/优化)
* 122 = 微信加好友(支持联调/报表展示,不支持优化)
* 123 = 注册(支持联调/报表展示,不支持优化)
* 124 = 微信小程序访问(支持联调/报表展示,不支持优化)
*
* @param string $type
* @return string
* @return int
*/
private function mapEventType($type)
{
$map = [
'register' => 'REGISTER',
'order' => 'ORDER',
'payment' => 'PURCHASE',
'add_wechat' => 'ADD_WECHAT',
'register' => 123, // 注册
'order' => 1, // 商品成交(下单)
// 'payment' => 1, // 商品成交(付款)
// 'add_wechat' => 122, // 微信加好友
'visit' => 124, // 微信小程序访问
];
return $map[$type] ?? strtoupper($type);
return $map[$type] ?? 0;
}
/**
......
......@@ -22,8 +22,8 @@ class ConversionLog extends Common
const STATUS_NO_TRACK = 3; // 无需回传(无有效 click_id)
const STATUS_EXPIRED = 4; // 追踪已过期
// 最大重试次数
const MAX_RETRY = 3;
// 最大重试次数(对齐小红书官方要求 30 次)
const MAX_RETRY = 30;
/**
* 状态文本映射
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment