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

小红书运营监控接口

parent cb8be815
<?php
namespace addons\shopro\controller\user;
use addons\shopro\controller\Common;
use app\admin\model\shopro\xhs\AdTracking as AdTrackingModel;
use app\admin\model\shopro\xhs\ConversionLog as ConversionLogModel;
/**
* 小红书聚光「种草直达-微信小程序」广告追踪 & 转化回传
*/
class XhsAd extends Common
{
protected $noNeedLogin = ['*'];
protected $noNeedRight = ['*'];
/**
* 接收追踪参数接口
* 小程序从广告跳入时调用,存储 click_id 与用户绑定
*
* POST /addons/shopro/user.xhs_ad/track
* @param click_id string 小红书广告点击唯一标识(必填)
* @param request_id string 小红书广告请求 ID
* @param event_type string 事件类型(如 122)
* @param landing_page string 落地页路径
*/
public function track()
{
if (!$this->request->isPost()) {
$this->error('请求方式错误');
}
$params = $this->request->only(['click_id', 'request_id', 'event_type', 'landing_page']);
$this->svalidate($params, '.track');
$user = auth_user();
$userId = $user->id ?? 0;
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($params['click_id'])) {
custom_log('[xhs.track] click_id 为空,拒绝记录', $userId, 'xhs_ad', 'warning');
$this->error('缺少 click_id 参数');
}
// 记录/更新追踪信息
$tracking = AdTrackingModel::record([
'user_id' => $userId,
'click_id' => $params['click_id'],
'request_id' => $params['request_id'] ?? '',
'event_type' => $params['event_type'] ?? '',
'landing_page' => $params['landing_page'] ?? '',
]);
if (!$tracking) {
custom_log('[xhs.track] 追踪记录写入失败, user_id=' . $userId, $userId, 'xhs_ad', 'warning');
$this->error('记录追踪信息失败');
}
custom_log('[xhs.track] 追踪记录成功, tracking_id=' . $tracking->id . ', click_id=' . $params['click_id'], $userId, 'xhs_ad');
$this->success('记录成功', [
'tracking_id' => $tracking->id,
]);
}
/**
* 转化事件记录接口
* 用户在小程序内完成转化后调用,触发异步回传
*
* POST /addons/shopro/user.xhs_ad/conversion
* @param conversion_type string 转化类型: register/order/payment/add_wechat(必填)
* @param value float 转化金额
* @param order_id string 订单号
*/
public function conversion()
{
if (!$this->request->isPost()) {
$this->error('请求方式错误');
}
$params = $this->request->only(['conversion_type', 'value', 'order_id']);
$this->svalidate($params, '.conversion');
$user = auth_user();
$userId = $user->id ?? 0;
custom_log('[xhs.conversion] 收到转化记录请求, user_id=' . $userId . ', params=' . json_encode($params, JSON_UNESCAPED_UNICODE), $userId, 'xhs_ad');
if (empty($userId)) {
custom_log('[xhs.conversion] user_id 为空', $userId, 'xhs_ad', 'warning');
$this->error('用户未登录');
}
$conversionType = $params['conversion_type'];
$value = floatval($params['value'] ?? 0);
$orderId = $params['order_id'] ?? '';
// 查询有效追踪记录
$tracking = AdTrackingModel::getValidByUser($userId);
$clickId = '';
$trackingId = 0;
$status = ConversionLogModel::STATUS_NO_TRACK;
if ($tracking) {
$clickId = $tracking->click_id;
$trackingId = $tracking->id;
$status = ConversionLogModel::STATUS_PENDING;
// 去重检查
if (ConversionLogModel::isDuplicated($clickId, $conversionType, $orderId)) {
custom_log('[xhs.conversion] 重复转化,拒绝记录, click_id=' . $clickId . ', type=' . $conversionType . ', order=' . $orderId, $userId, 'xhs_ad', 'warning');
$this->error('该转化事件已记录,请勿重复提交');
}
} else {
custom_log('[xhs.conversion] 无有效追踪记录,记录为无需回传, user_id=' . $userId, $userId, 'xhs_ad', 'warning');
}
$now = time();
// 写入转化日志
try {
$log = ConversionLogModel::create([
'user_id' => $userId,
'tracking_id' => $trackingId,
'click_id' => $clickId,
'conversion_type' => $conversionType,
'value' => $value,
'order_id' => $orderId,
'status' => $status,
'retry_count' => 0,
'conversion_time' => $now,
'createtime' => $now,
'updatetime' => $now,
]);
custom_log('[xhs.conversion] 转化日志写入成功, log_id=' . $log->id . ', status=' . $status, $userId, 'xhs_ad');
} catch (\Exception $e) {
custom_log('[xhs.conversion] 转化日志写入失败: ' . $e->getMessage(), $userId, 'xhs_ad', 'error');
$this->error('记录转化事件失败');
}
// 如果有有效 click_id,投递异步队列回传
if ($status === ConversionLogModel::STATUS_PENDING && !empty($clickId)) {
try {
$jobData = [
'log_id' => $log->id,
'user_id' => $userId,
'click_id' => $clickId,
'conversion_type' => $conversionType,
'value' => $value,
'order_id' => $orderId,
'conversion_time' => $now,
];
\think\Queue::push('addons\shopro\job\XhsConversionReport', $jobData, 'shopro');
custom_log('[xhs.conversion] 异步队列投递成功, log_id=' . $log->id, $userId, 'xhs_ad');
} catch (\Exception $e) {
custom_log('[xhs.conversion] 异步队列投递失败: ' . $e->getMessage(), $userId, 'xhs_ad', 'error');
// 队列投递失败不阻塞主流程,后续可手动重试
}
}
$this->success('转化事件记录成功', [
'log_id' => $log->id,
'status' => $status,
]);
}
}
<?php
namespace addons\shopro\job;
use think\queue\Job;
use addons\shopro\service\xhs\ConversionService;
/**
* 小红书转化回传异步队列任务
*
* 用法:
* \think\Queue::push('addons\shopro\job\XhsConversionReport', $data, 'shopro');
*
* $data 参数:
* - log_id int 转化日志 ID
* - user_id int 用户 ID
* - click_id string 广告点击 ID
* - conversion_type string 转化类型
* - value float 转化金额
* - order_id string 订单号
* - conversion_time int 转化发生时间(秒级时间戳)
*/
class XhsConversionReport extends BaseJob
{
/**
* 任务失败重试次数上限
*/
const MAX_ATTEMPTS = 3;
/**
* 执行队列任务
*
* @param Job $job 队列任务
* @param array $data 任务数据
*/
public function exec(Job $job, $data)
{
$logId = $data['log_id'] ?? 0;
$userId = $data['user_id'] ?? 0;
custom_log('[xhs.job] 队列任务开始执行, log_id=' . $logId . ', user_id=' . $userId, $userId, 'xhs_ad');
try {
// 校验入参
if (empty($logId) || empty($data['click_id'])) {
custom_log('[xhs.job] 入参有空值,删除job, log_id=' . $logId, $userId, 'xhs_ad', 'warning');
$job->delete();
return false;
}
$service = new ConversionService();
$result = $service->handleReport($data);
if ($result) {
custom_log('[xhs.job] 回传成功,删除job, log_id=' . $logId, $userId, 'xhs_ad');
$job->delete();
return true;
} else {
$attempts = $job->attempts();
if ($attempts >= self::MAX_ATTEMPTS) {
custom_log('[xhs.job] 已达最大尝试次数(' . self::MAX_ATTEMPTS . '),删除job, log_id=' . $logId, $userId, 'xhs_ad', 'error');
$job->delete();
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);
return false;
}
} catch (\Exception $e) {
custom_log('[xhs.job] 队列异常: ' . $e->getMessage() . ', log_id=' . $logId, $userId, 'xhs_ad', 'error');
format_log_error_custom_name($e, '[XHS转化回传] log_id:' . $logId, '', 'xhs_ad');
// 超过重试次数则删除
if ($job->attempts() >= self::MAX_ATTEMPTS) {
$job->delete();
return false;
}
// 异常时 60s 后重试
$job->release(60);
return false;
}
}
}
<?php
namespace addons\shopro\service\xhs;
use app\admin\model\shopro\xhs\ConversionLog as ConversionLogModel;
/**
* 小红书聚光 Marketing API 转化回传服务
*
* 负责调用小红书开放平台接口回传转化事件
* 包含 Token 管理、API 调用、重试逻辑
*
* ⚠️ 使用前需在聚光开放平台 (ad-market.xiaohongshu.com) 申请应用,
* 并在环境变量中配置 PHP_XHS_APP_KEY / PHP_XHS_APP_SECRET
*/
class ConversionService
{
// API 基础地址(以小红书开放平台官方文档为准)
const API_BASE = 'https://ad-market.xiaohongshu.com/open/api';
// Token 接口
const TOKEN_URL = '/oauth/token';
// 转化回传接口(具体路径以开放平台文档为准)
const CONVERSION_URL = '/conversion/upload';
// Token 缓存 key 前缀
const TOKEN_CACHE_KEY = 'shopro:xhs:access_token';
// Token 提前刷新时间(秒),在过期前 5 分钟刷新
const TOKEN_REFRESH_AHEAD = 300;
/**
* 获取 Access Token(带缓存)
*
* @return string|null
* @throws \Exception
*/
public function getAccessToken()
{
// 检查缓存
$cached = redis_cache('?' . self::TOKEN_CACHE_KEY);
if ($cached) {
$tokenData = json_decode($cached, true);
if ($tokenData && isset($tokenData['token'])) {
$now = time();
if ($tokenData['expires_at'] > ($now + self::TOKEN_REFRESH_AHEAD)) {
return $tokenData['token'];
}
}
}
// 获取新 Token
return $this->refreshAccessToken();
}
/**
* 强制刷新 Access Token
*
* @return string|null
* @throws \Exception
*/
private function refreshAccessToken()
{
$appKey = $this->getConfig('APP_KEY', '');
$appSecret = $this->getConfig('APP_SECRET', '');
if (empty($appKey) || empty($appSecret)) {
throw new \Exception('小红书聚光 API 凭证未配置(PHP_XHS_APP_KEY / PHP_XHS_APP_SECRET)');
}
$url = self::API_BASE . self::TOKEN_URL;
$params = [
'app_key' => $appKey,
'app_secret' => $appSecret,
'grant_type' => 'client_credentials',
];
$response = $this->httpPost($url, $params);
if (empty($response['access_token'])) {
throw new \Exception('获取 access_token 失败: ' . json_encode($response));
}
$token = $response['access_token'];
$expiresIn = intval($response['expires_in'] ?? 7200);
// 缓存 Token
$tokenData = [
'token' => $token,
'expires_at' => time() + $expiresIn,
];
redis_cache(self::TOKEN_CACHE_KEY, json_encode($tokenData), $expiresIn);
return $token;
}
/**
* 回传转化事件到小红书
*
* @param array $data 转化数据
* - click_id string 广告点击 ID
* - conversion_type string 转化类型
* - value float 转化金额
* - order_id string 订单号
* - conversion_time int 转化发生时间(秒级时间戳)
* @return array ['success' => bool, 'response' => mixed]
*/
public function reportConversion($data)
{
$clickId = $data['click_id'] ?? '';
$conversionType = $data['conversion_type'] ?? '';
if (empty($clickId)) {
return ['success' => false, 'response' => 'click_id 为空'];
}
try {
$accessToken = $this->getAccessToken();
} catch (\Exception $e) {
return ['success' => false, 'response' => '获取 Token 失败: ' . $e->getMessage()];
}
// 转换事件类型为小红书平台编码(具体编码以开放平台文档为准)
$eventType = $this->mapEventType($conversionType);
$url = self::API_BASE . self::CONVERSION_URL;
$body = [
'click_id' => $clickId,
'event_type' => $eventType,
'event_time' => intval($data['conversion_time'] ?? 0),
'conversion_value' => floatval($data['value'] ?? 0),
];
// 有订单号时附加
if (!empty($data['order_id'])) {
$body['order_id'] = $data['order_id'];
}
$headers = [
'Authorization: Bearer ' . $accessToken,
'Content-Type: application/json',
];
$result = $this->httpPostJson($url, $body, $headers);
// 判断回传是否成功(根据小红书返回结构判断)
if (isset($result['code']) && $result['code'] === 0) {
return ['success' => true, 'response' => json_encode($result)];
}
return [
'success' => false,
'response' => json_encode($result),
'err_msg' => $result['msg'] ?? ($result['message'] ?? '未知错误'),
];
}
/**
* 处理转化回传(含重试逻辑)
*
* @param array $jobData 队列数据
* @return bool
*/
public function handleReport($jobData)
{
$logId = $jobData['log_id'] ?? 0;
$userId = $jobData['user_id'] ?? 0;
custom_log('[xhs.report] 开始处理转化回传, log_id=' . $logId . ', data=' . json_encode($jobData, JSON_UNESCAPED_UNICODE), $userId, 'xhs_ad');
// 查询日志记录
$log = ConversionLogModel::find($logId);
if (!$log) {
custom_log('[xhs.report] 转化日志不存在, log_id=' . $logId, $userId, 'xhs_ad', 'warning');
return false;
}
// 非待回传状态跳过
if ($log->status != ConversionLogModel::STATUS_PENDING) {
custom_log('[xhs.report] 日志状态非待回传,跳过, log_id=' . $logId . ', status=' . $log->status, $userId, 'xhs_ad', 'warning');
return true;
}
// 超过最大重试次数
if ($log->retry_count >= ConversionLogModel::MAX_RETRY) {
$log->status = ConversionLogModel::STATUS_FAILED;
$log->api_err_msg = '超过最大重试次数(' . ConversionLogModel::MAX_RETRY . ')';
$log->updatetime = time();
$log->save();
custom_log('[xhs.report] 超过最大重试次数,标记失败, log_id=' . $logId, $userId, 'xhs_ad', 'error');
return false;
}
// 调用回传
$result = $this->reportConversion($jobData);
$now = time();
$log->retry_count = $log->retry_count + 1;
$log->report_time = $now;
$log->updatetime = $now;
if ($result['success']) {
$log->status = ConversionLogModel::STATUS_SUCCESS;
$log->api_response = $result['response'];
$log->save();
custom_log('[xhs.report] 转化回传成功, log_id=' . $logId, $userId, 'xhs_ad');
return true;
} else {
$log->status = ($log->retry_count >= ConversionLogModel::MAX_RETRY)
? ConversionLogModel::STATUS_FAILED
: ConversionLogModel::STATUS_PENDING;
$log->api_response = $result['response'] ?? '';
$log->api_err_msg = $result['err_msg'] ?? 'API 返回异常';
$log->save();
custom_log('[xhs.report] 转化回传失败, log_id=' . $logId . ', retry=' . $log->retry_count . ', err=' . $log->api_err_msg, $userId, 'xhs_ad', 'error');
return false;
}
}
/**
* 转化类型映射:内部类型 → 小红书事件编码
* ⚠️ 具体编码以小红书开放平台文档为准
*
* @param string $type
* @return string
*/
private function mapEventType($type)
{
$map = [
'register' => 'REGISTER',
'order' => 'ORDER',
'payment' => 'PURCHASE',
'add_wechat' => 'ADD_WECHAT',
];
return $map[$type] ?? strtoupper($type);
}
/**
* 获取配置(优先环境变量,其次 shopro 配置组)
*
* @param string $key
* @param mixed $default
* @return mixed
*/
private function getConfig($key, $default = null)
{
// 1. 优先环境变量(如 PHP_XHS_APP_KEY / PHP_XHS_APP_SECRET)
$envKey = 'PHP_XHS_' . strtoupper($key);
$envValue = getenv($envKey);
if ($envValue !== false && $envValue !== '') {
return $envValue;
}
// 2. 从 shopro 配置组中获取(需先在 fa_shopro_config 表添加 code=xhs_ad 的配置组)
$config = sheep_config('xhs_ad');
if ($config && isset($config[$key])) {
return $config[$key];
}
return $default;
}
/**
* HTTP POST (form-urlencoded)
*/
private function httpPost($url, $data)
{
try {
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($data));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
curl_setopt($ch, CURLOPT_TIMEOUT, 10);
$result = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$error = curl_error($ch);
curl_close($ch);
if ($error) {
custom_log('[xhs.api] curl error: ' . $error, 0, 'xhs_ad', 'error');
return null;
}
if ($httpCode != 200) {
custom_log('[xhs.api] http error: ' . $httpCode . ', body=' . substr($result, 0, 500), 0, 'xhs_ad', 'error');
return null;
}
return json_decode($result, true) ?: null;
} catch (\Exception $e) {
custom_log('[xhs.api] POST 请求异常: ' . $e->getMessage(), 0, 'xhs_ad', 'error');
return null;
}
}
/**
* HTTP POST (JSON body)
*/
private function httpPostJson($url, $data, $headers = [])
{
try {
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
curl_setopt($ch, CURLOPT_TIMEOUT, 15);
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
$result = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$error = curl_error($ch);
curl_close($ch);
if ($error) {
custom_log('[xhs.api] curl error: ' . $error, 0, 'xhs_ad', 'error');
return null;
}
if ($httpCode != 200) {
custom_log('[xhs.api] http error: ' . $httpCode . ', body=' . substr($result, 0, 500), 0, 'xhs_ad', 'error');
}
return json_decode($result, true) ?: null;
} catch (\Exception $e) {
custom_log('[xhs.api] POST JSON 请求异常: ' . $e->getMessage(), 0, 'xhs_ad', 'error');
return null;
}
}
}
<?php
namespace addons\shopro\validate\user;
use think\Validate;
class XhsAd extends Validate
{
protected $rule = [
'click_id' => 'require|length:0,128',
'request_id' => 'length:0,128',
'event_type' => 'length:0,32',
'landing_page' => 'length:0,512',
'conversion_type' => 'require|in:register,order,payment,add_wechat',
'value' => 'float',
'order_id' => 'length:0,64',
];
protected $message = [
'click_id.require' => 'click_id 参数缺失',
'click_id.length' => 'click_id 长度不能超过 128 位',
'request_id.length' => 'request_id 长度不能超过 128 位',
'event_type.length' => 'event_type 长度不能超过 32 位',
'landing_page.length' => 'landing_page 长度不能超过 512 位',
'conversion_type.require' => '请选择转化类型',
'conversion_type.in' => '转化类型不合法',
'value.float' => '转化金额必须为数字',
'order_id.length' => 'order_id 长度不能超过 64 位',
];
protected $scene = [
'track' => ['click_id', 'request_id', 'event_type', 'landing_page'],
'conversion' => ['conversion_type', 'value', 'order_id'],
];
}
<?php
namespace app\admin\model\shopro\xhs;
use app\admin\model\shopro\Common;
/**
* 小红书广告追踪模型
*/
class AdTracking extends Common
{
protected $name = 'xhs_ad_tracking';
protected $updateTime = 'updatetime';
protected $createTime = 'createtime';
protected $autoWriteTimestamp = 'integer';
// 30 天归因窗口(秒)
const ATTRIBUTION_WINDOW = 30 * 86400;
// 平台来源常量
const PLATFORM_MINIPROGRAM = 'WechatMiniProgram';
/**
* 记录广告追踪信息
* 同一用户在归因期内多次点击,更新为最新的 click_id
*
* @param array $params
* @return AdTracking|null
*/
public static function record($params)
{
$userId = $params['user_id'] ?? 0;
$clickId = $params['click_id'] ?? '';
$platform = $params['platform'] ?? self::PLATFORM_MINIPROGRAM;
if (empty($userId) || empty($clickId)) {
return null;
}
$now = time();
$expiresAt = $now + self::ATTRIBUTION_WINDOW;
// 查询已有记录(未过期)
$exist = self::where('user_id', $userId)
->where('expires_at', '>', $now)
->find();
if ($exist) {
// 更新为最新 click_id
$exist->click_id = $clickId;
$exist->request_id = $params['request_id'] ?? $exist->request_id;
$exist->event_type = $params['event_type'] ?? $exist->event_type;
$exist->landing_page = $params['landing_page'] ?? $exist->landing_page;
$exist->expires_at = $expiresAt;
$exist->updatetime = $now;
$exist->save();
return $exist;
}
// 新建记录
return self::create([
'user_id' => $userId,
'click_id' => $clickId,
'request_id' => $params['request_id'] ?? '',
'event_type' => $params['event_type'] ?? '',
'landing_page' => $params['landing_page'] ?? '',
'platform' => $platform,
'createtime' => $now,
'updatetime' => $now,
'expires_at' => $expiresAt,
]);
}
/**
* 根据 user_id 获取有效期内的有效 click_id
*
* @param int $userId
* @return AdTracking|null
*/
public static function getValidByUser($userId)
{
$now = time();
return self::where('user_id', $userId)
->where('expires_at', '>', $now)
->order('id', 'desc')
->find();
}
}
<?php
namespace app\admin\model\shopro\xhs;
use app\admin\model\shopro\Common;
/**
* 小红书转化回传日志模型
*/
class ConversionLog extends Common
{
protected $name = 'xhs_conversion_log';
protected $updateTime = 'updatetime';
protected $createTime = 'createtime';
protected $autoWriteTimestamp = 'integer';
// 回传状态
const STATUS_PENDING = 0; // 待回传
const STATUS_SUCCESS = 1; // 回传成功
const STATUS_FAILED = 2; // 回传失败
const STATUS_NO_TRACK = 3; // 无需回传(无有效 click_id)
const STATUS_EXPIRED = 4; // 追踪已过期
// 最大重试次数
const MAX_RETRY = 3;
/**
* 状态文本映射
* @return array
*/
public function statusList()
{
return [
self::STATUS_PENDING => '待回传',
self::STATUS_SUCCESS => '回传成功',
self::STATUS_FAILED => '回传失败',
self::STATUS_NO_TRACK => '无需回传',
self::STATUS_EXPIRED => '追踪已过期',
];
}
/**
* 去重检查:同一 click_id + conversion_type + order_id 不可重复回传
*
* @param string $clickId
* @param string $conversionType
* @param string $orderId
* @return bool
*/
public static function isDuplicated($clickId, $conversionType, $orderId)
{
if (empty($clickId) || empty($conversionType)) {
return false;
}
$where = [
'click_id' => $clickId,
'conversion_type' => $conversionType,
];
if (!empty($orderId)) {
$where['order_id'] = $orderId;
}
return self::where($where)->find() ? true : false;
}
}
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