ApkService.php 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717
  1. <?php
  2. namespace App\Services;
  3. use App\Facade\TableName;
  4. use App\Http\Controllers\Api\AgentClickController;
  5. use App\Http\helper\HttpCurl;
  6. use App\Http\helper\NumConfig;
  7. use App\IpLocation;
  8. use App\Models\AccountsSource;
  9. use App\Util;
  10. use Illuminate\Support\Facades\DB;
  11. use Illuminate\Support\Facades\Log;
  12. use Illuminate\Support\Facades\Redis;
  13. /**
  14. * AccountCookie 广告归因服务。
  15. *
  16. * 核心职责:管理 AccountCookie 表,将用户的广告点击(Facebook / Google / Kwai)
  17. * 与注册账号关联起来,用于后续的 S2S 事件上报(CAPI / Kwai / Google Offline Conversion)。
  18. *
  19. * 关键概念:
  20. * - AccountCookie:落地页 JS 上报的广告点击信息(_fbc、fbclid、gclid 等),存入 DB。
  21. * - FPID (BrowserFingerPrintID):浏览器指纹 ID,前端通过 FingerprintJS 生成。
  22. * - FF (FontFingerprint):字体指纹哈希,辅助设备识别。
  23. * - SPE_KEY:quickSave 时生成的短哈希,用于跨页面快速匹配。
  24. * - fbclid:Facebook 广告点击 ID,从 _fbc cookie 中提取(格式 fb.1.{ts}.{fbclid}),
  25. * 同一次广告点击在不同浏览器中 fbclid 相同,是跨浏览器匹配的关键依据。
  26. * - UrlSign:渠道号(Channel),用于区分不同推广渠道。
  27. *
  28. * 数据流:
  29. * 1. 用户点击广告 → 落地页 JS 调用 saveEnv/quickSave → saveCookie() 写入 AccountCookie(UserID=0)
  30. * 2. 用户注册 → bindCookie() 将注册的 UserID 回填到匹配的 AccountCookie 记录
  31. * 3. 用户登录/充值 → loadCookie() 读取 AccountCookie → AfEvent 上报 Facebook CAPI
  32. *
  33. * 匹配安全原则:
  34. * - "尽量不绑错" 优先于 "尽量绑上"
  35. * - 绝不覆盖已绑定其他 UserID 的记录,避免串号
  36. * - 跨渠道纠偏必须满足:唯一命中(或 fbclid 精准匹配) + 时间窗口内
  37. */
  38. class ApkService
  39. {
  40. /**
  41. * 按 UserID 更新已有的 AccountCookie 记录(充值/首存等事件触发后更新 UniqueAction)。
  42. * 仅用于已确定 UserID 的场景,不涉及指纹匹配,安全无串号风险。
  43. */
  44. public static function updateCookie($UserID, $arr)
  45. {
  46. DB::table(TableName::QPAccountsDB() . 'AccountCookie')->updateOrInsert(['UserID' => $UserID], $arr);
  47. $key = "AccountCookie_$UserID";
  48. Redis::set($key, json_encode($arr));
  49. Redis::expire($key, 36000);
  50. }
  51. /**
  52. * 保存广告点击的 Cookie 数据到 AccountCookie 表。
  53. *
  54. * 查重优先级:SPE_KEY > UserID > FPID > FF。
  55. * 安全保护:如果通过 FPID/FF 找到的记录已绑定了其他 UserID,
  56. * 不覆盖旧记录,而是插入新记录,防止串号。
  57. */
  58. public static function saveCookie($UserID, $data, $FPID = '', $FF = '',$SPE_KEY='')
  59. {
  60. $agent = $_SERVER['HTTP_USER_AGENT'];
  61. $table = TableName::QPAccountsDB() . 'AccountCookie';
  62. $checkKey = "";
  63. $checkValue = "";
  64. if (!empty($SPE_KEY)) {
  65. $checkKey = 'SPE_KEY';
  66. $checkValue = $SPE_KEY;
  67. } else if (!empty($UserID)) {
  68. $checkKey = 'UserID';
  69. $checkValue = $UserID;
  70. } else if (!empty($FPID)) {
  71. $checkKey = 'FPID';
  72. $checkValue = $FPID;
  73. } else if (!empty($FF)) {
  74. $checkKey = 'FF';
  75. $checkValue = $FF;
  76. }
  77. if (empty($checkKey)) return;
  78. $existing = DB::table($table)->where($checkKey, $checkValue)->first();
  79. if(isset($data['localStorage'])&&!empty($data['localStorage'])){
  80. $ls=json_decode($data['localStorage'],true);
  81. if(isset($ls['user']))unset($ls['user']);
  82. $data['localStorage']=json_encode($ls);
  83. }
  84. $arr = [
  85. 'UserID' => $UserID,
  86. 'FPID' => $FPID,
  87. 'FF' => $FF,
  88. 'SPE_KEY' => $SPE_KEY,
  89. 'Cookie' => $data['cookie'],
  90. 'Params' => $data['params'],
  91. 'LocalStorage' => $data['localStorage']?? "",
  92. 'UrlSign' => $data['url_sign'],
  93. 'Platform' => $data['type'],
  94. 'GameUA' => $agent,
  95. 'ClickUA' => $data['agent'],
  96. 'Origin' => $data['origin'] ?? "",
  97. 'Locale' => $data['locale'] ?? $_SERVER['HTTP_ACCEPT_LANGUAGE'],
  98. 'ClickTime' => date('Y-m-d H:i:s', $data['time']),
  99. 'UniqueAction' => 2,
  100. 'IP' => IpLocation::getIP(),
  101. ];
  102. if (!$existing) {
  103. DB::table($table)->insert($arr);
  104. if (!empty($UserID)) {
  105. if (strstr($arr['Params'], 'gclid') || strstr($arr['Params'], 'fbclid')) {
  106. (new AccountsSource())->notExistsInsert($UserID, "APK_AD(" . $arr['Platform'] . ")");
  107. }
  108. }
  109. if ($arr['Platform'] == 'kw') {
  110. $cookie = $arr['Cookie'];
  111. }
  112. if ($arr['Platform'] == 'gg') {
  113. $cookie = $arr['Params'];
  114. }
  115. }else if(!empty($data['cookie'])){
  116. $existingUserID = $existing->UserID ?? 0;
  117. if (!empty($existingUserID) && $existingUserID != 0
  118. && !empty($UserID) && $UserID != $existingUserID) {
  119. $arr['UserID'] = $UserID;
  120. DB::table($table)->insert($arr);
  121. return;
  122. }
  123. $shouldInsertNew = false;
  124. if (in_array($checkKey, ['FPID', 'FF'])) {
  125. $newFbclid = self::extractFbclid($data['cookie'] ?? '');
  126. $existingFbclid = self::extractFbclid($existing->Cookie ?? '');
  127. $newChannel = $data['url_sign'] ?? '';
  128. $existingChannel = $existing->UrlSign ?? '';
  129. if (!empty($newFbclid) && !empty($existingFbclid) && $newFbclid !== $existingFbclid) {
  130. $shouldInsertNew = true;
  131. } elseif (!empty($newChannel) && !empty($existingChannel) && $newChannel !== $existingChannel) {
  132. $newParamsFbclid = '';
  133. $existingParamsFbclid = '';
  134. $newParams = json_decode($data['params'] ?? '', true);
  135. $existingParams = json_decode($existing->Params ?? '', true);
  136. if (is_array($newParams)) $newParamsFbclid = $newParams['fbclid'] ?? '';
  137. if (is_array($existingParams)) $existingParamsFbclid = $existingParams['fbclid'] ?? '';
  138. if (!empty($newParamsFbclid) && !empty($existingParamsFbclid) && $newParamsFbclid !== $existingParamsFbclid) {
  139. $shouldInsertNew = true;
  140. }
  141. }
  142. }
  143. if ($shouldInsertNew) {
  144. DB::table($table)->insert($arr);
  145. } else {
  146. if (!empty($existingUserID) && $existingUserID != 0) {
  147. $arr['UserID'] = $existingUserID;
  148. }
  149. DB::table($table)->where('ID', $existing->ID)->update($arr);
  150. }
  151. }
  152. }
  153. /**
  154. * 注册后将 UserID 回填到匹配的 AccountCookie 记录。
  155. *
  156. * 两阶段匹配:
  157. * 1. 同渠道(sameChannel):UrlSign == Channel,匹配即绑。
  158. * 2. 跨渠道纠偏(crossChannel):FBIOS → Safari/PWA 跳转导致渠道不一致时的兜底。
  159. * 额外约束:唯一命中(或 fbclid 精准匹配) + 时间窗口内。
  160. * 成功后把用户 Channel 纠正为 Cookie 的 UrlSign(同步 GlobalUserInfo + AccountsInfo)。
  161. *
  162. * 匹配器优先级(高→低):
  163. * SPE_KEY > FPID+FF+IP > FPID+FF > FPID+IP > FF+IP > FPID > FF > IP(仅跨渠道,窗口5min)
  164. *
  165. * 多条候选时用 fbclid 精准匹配,匹配不到则取最新。
  166. *
  167. * @param object &$user GlobalUserInfo(引用,跨渠道命中会修改 Channel)
  168. * @param string $SPE_KEY quickSave 生成的特征哈希
  169. * @param string $fbclid 从 _fbc cookie 提取的 Facebook 点击 ID
  170. * @param string $cookie 客户端上报的完整 cookie 串(与 API 域名不同域时需 body 传参)
  171. * @return object|false 更新后的 AccountCookie 记录,或 false
  172. */
  173. public static function bindCookie(&$user, $SPE_KEY='', $fbclid='', $cookie='')
  174. {
  175. // 注册成功后回填 AccountCookie 的 UserID。
  176. // 这里的目标不是“尽量绑上”,而是“尽量不绑错”。
  177. $UserID=$user->UserID;
  178. $FPID=$user->FPID;
  179. $FF=$user->FF;
  180. $IP=$user->RegisterIP;
  181. $Channel = $user->Channel ?? '';
  182. $registerTime = !empty($user->RegisterDate) ? strtotime($user->RegisterDate) : time();
  183. if (empty($UserID)) {
  184. return false;
  185. }
  186. $logContext = [
  187. 'user_id' => $UserID,
  188. 'channel' => $Channel,
  189. 'fpid' => $FPID,
  190. 'ff' => $FF,
  191. 'ip' => $IP,
  192. 'spe_key' => $SPE_KEY,
  193. 'fbclid' => $fbclid,
  194. 'cookie_has_fbc' => is_string($cookie) && strpos($cookie, '_fbc') !== false,
  195. 'register_date' => $user->RegisterDate ?? '',
  196. ];
  197. $table = TableName::QPAccountsDB() . 'AccountCookie';
  198. $updateArr = ['UserID' => $UserID];
  199. if (!empty($FPID)) {
  200. $updateArr['FPID'] = $FPID;
  201. }
  202. if (!empty($FF)) {
  203. $updateArr['FF'] = $FF;
  204. }
  205. if (!empty($SPE_KEY)) {
  206. $updateArr['SPE_KEY'] = $SPE_KEY;
  207. }
  208. $baseQuery = function () use ($table) {
  209. // 只允许回填原始未绑定用户的数据,避免覆盖已经有 UserID 的老记录。
  210. return DB::table($table)
  211. ->where('UserID', 0);
  212. };
  213. $sameChannelMatchers = [];
  214. $crossChannelMatchers = [];
  215. // 匹配强度从高到低排列:越靠前越可信。
  216. // 先跑“同渠道”命中;只有完全找不到时,才尝试“跨渠道纠偏”。
  217. if (!empty($SPE_KEY)) {
  218. $sameChannelMatchers[] = ['label' => 'same_channel:SPE_KEY', 'query' => $baseQuery()->where('SPE_KEY', $SPE_KEY)];
  219. $crossChannelMatchers[] = ['label' => 'cross_channel:SPE_KEY', 'query' => $baseQuery()->where('SPE_KEY', $SPE_KEY)];
  220. }
  221. if (!empty($FPID) && !empty($FF) && !empty($IP)) {
  222. $sameChannelMatchers[] = ['label' => 'same_channel:FPID+FF+IP', 'query' => $baseQuery()->where('FPID', $FPID)->where('FF', $FF)->where('IP', $IP)];
  223. $crossChannelMatchers[] = ['label' => 'cross_channel:FPID+FF+IP', 'query' => $baseQuery()->where('FPID', $FPID)->where('FF', $FF)->where('IP', $IP)];
  224. }
  225. if (!empty($FPID) && !empty($FF)) {
  226. $sameChannelMatchers[] = ['label' => 'same_channel:FPID+FF', 'query' => $baseQuery()->where('FPID', $FPID)->where('FF', $FF)];
  227. $crossChannelMatchers[] = ['label' => 'cross_channel:FPID+FF', 'query' => $baseQuery()->where('FPID', $FPID)->where('FF', $FF)];
  228. }
  229. if (!empty($FPID) && !empty($IP)) {
  230. $sameChannelMatchers[] = ['label' => 'same_channel:FPID+IP', 'query' => $baseQuery()->where('FPID', $FPID)->where('IP', $IP)];
  231. $crossChannelMatchers[] = ['label' => 'cross_channel:FPID+IP', 'query' => $baseQuery()->where('FPID', $FPID)->where('IP', $IP)];
  232. }
  233. if (!empty($FF) && !empty($IP)) {
  234. $sameChannelMatchers[] = ['label' => 'same_channel:FF+IP', 'query' => $baseQuery()->where('FF', $FF)->where('IP', $IP)];
  235. $crossChannelMatchers[] = ['label' => 'cross_channel:FF+IP', 'query' => $baseQuery()->where('FF', $FF)->where('IP', $IP)];
  236. }
  237. if (!empty($FPID)) {
  238. $sameChannelMatchers[] = ['label' => 'same_channel:FPID', 'query' => $baseQuery()->where('FPID', $FPID)];
  239. $crossChannelMatchers[] = ['label' => 'cross_channel:FPID', 'query' => $baseQuery()->where('FPID', $FPID)];
  240. }
  241. if (!empty($FF)) {
  242. $sameChannelMatchers[] = ['label' => 'same_channel:FF', 'query' => $baseQuery()->where('FF', $FF)];
  243. $crossChannelMatchers[] = ['label' => 'cross_channel:FF', 'query' => $baseQuery()->where('FF', $FF)];
  244. }
  245. if (!empty($IP)) {
  246. $crossChannelMatchers[] = [
  247. 'label' => 'cross_channel:IP_only',
  248. 'query' => $baseQuery()->where('IP', $IP),
  249. 'time_window' => 300,
  250. ];
  251. }
  252. foreach ($sameChannelMatchers as $matcher) {
  253. $query = $matcher['query'];
  254. if (!empty($Channel)) {
  255. $query->where('UrlSign', $Channel);
  256. }
  257. $candidates = (clone $query)
  258. ->orderBy('CreateTime', 'desc')
  259. ->orderBy('ID', 'desc')
  260. ->get();
  261. $record = self::pickBestCandidate($candidates, $fbclid);
  262. if (!$record) {
  263. continue;
  264. }
  265. Util::WriteLog('bindCookie', $logContext + [
  266. 'stage' => 'matched',
  267. 'matcher' => $matcher['label'],
  268. 'before' => (array)$record,
  269. ]);
  270. $rowUpdate = self::applyFbcCookieBackfillToUpdate($updateArr, $record, $cookie);
  271. DB::table($table)->where('ID', $record->ID)->update($rowUpdate);
  272. $updatedRecord = DB::table($table)->where('ID', $record->ID)->first();
  273. Util::WriteLog('bindCookie', $logContext + [
  274. 'stage' => 'updated',
  275. 'matcher' => $matcher['label'],
  276. 'after' => (array)$updatedRecord,
  277. ]);
  278. return $updatedRecord;
  279. }
  280. foreach ($crossChannelMatchers as $matcher) {
  281. $query = $matcher['query'];
  282. if (!empty($Channel)) {
  283. $query->where('UrlSign', '<>', strval($Channel));
  284. }
  285. $records = (clone $query)
  286. ->orderBy('CreateTime', 'desc')
  287. ->orderBy('ID', 'desc')
  288. ->get();
  289. if ($records->isEmpty()) {
  290. continue;
  291. }
  292. if ($records->count() > 1 && empty($fbclid)) {
  293. continue;
  294. }
  295. $record = self::pickBestCandidate($records, $fbclid);
  296. $cookieTime = strtotime($record->CreateTime);
  297. $maxWindow = $matcher['time_window'] ?? 600;
  298. if ($cookieTime === false || abs($cookieTime - $registerTime) > $maxWindow) {
  299. Util::WriteLog('bindCookie', $logContext + [
  300. 'stage' => 'skipped',
  301. 'matcher' => $matcher['label'],
  302. 'reason' => 'time_window_exceeded',
  303. 'candidate' => (array)$record,
  304. ]);
  305. // 纠偏只接受注册前后 10 分钟内的 Cookie,防止把历史 Cookie 误绑到新用户。
  306. continue;
  307. }
  308. Util::WriteLog('bindCookie', $logContext + [
  309. 'stage' => 'matched',
  310. 'matcher' => $matcher['label'],
  311. 'before' => (array)$record,
  312. 'channel_corrected_to' => $record->UrlSign,
  313. ]);
  314. $rowUpdate = self::applyFbcCookieBackfillToUpdate($updateArr, $record, $cookie);
  315. DB::table($table)->where('ID', $record->ID)->update($rowUpdate);
  316. if (!empty($record->UrlSign) && isset($user->Channel) && $user->Channel != $record->UrlSign) {
  317. $oldChannel = $user->Channel;
  318. $user->Channel = $record->UrlSign;
  319. try {
  320. $user->save();
  321. } catch (\Exception $exception) {
  322. Log::error($exception->getMessage());
  323. }
  324. DB::table(TableName::QPAccountsDB() . 'AccountsInfo')
  325. ->where('UserID', $UserID)
  326. ->update(['Channel' => $record->UrlSign]);
  327. $registerDateId = !empty($user->RegisterDate)
  328. ? date('Ymd', strtotime($user->RegisterDate))
  329. : date('Ymd');
  330. AgentClickController::changePlatformData((int) $oldChannel, (int) $record->UrlSign, $registerDateId);
  331. Util::WriteLog('bindCookie', $logContext + [
  332. 'stage' => 'channel_corrected',
  333. 'old_channel' => $oldChannel,
  334. 'new_channel' => $record->UrlSign,
  335. ]);
  336. }
  337. $updatedRecord = DB::table($table)->where('ID', $record->ID)->first();
  338. Util::WriteLog('bindCookie', $logContext + [
  339. 'stage' => 'updated',
  340. 'matcher' => $matcher['label'],
  341. 'after' => (array)$updatedRecord,
  342. 'channel_corrected_to' => $user->Channel ?? '',
  343. ]);
  344. return $updatedRecord;
  345. }
  346. Util::WriteLog('bindCookie', $logContext + ['stage' => 'no_match']);
  347. return false;
  348. }
  349. /**
  350. * 加载用户的 AccountCookie 记录,用于 S2S 事件上报(CAPI / Kwai / Google)。
  351. *
  352. * 查找优先级:UserID 精确查 → FPID/FF 指纹查。
  353. * 多条候选时用 fbclid 精准匹配,匹配不到则取最新。
  354. * 通过 FPID/FF 找到记录后,仅补充空缺字段(FPID/FF/UserID),
  355. * 绝不覆盖已有的 UserID,防止串号。
  356. *
  357. * @param string $UserID 用户 ID
  358. * @param string $FPID 浏览器指纹 ID
  359. * @param string $FF 字体指纹哈希
  360. * @param string $cookie _fbc cookie 值或完整 cookie 字符串,用于 fbclid 匹配
  361. * @return array|null AccountCookie 记录数组,或 null
  362. */
  363. public static function loadCookie($UserID='', $FPID = '', $FF = '', $cookie = '')
  364. {
  365. $table = TableName::QPAccountsDB() . 'AccountCookie';
  366. $ac = null;
  367. $fbclid = self::extractFbclid($cookie);
  368. if (!empty($UserID)) {
  369. $candidates = DB::table($table)
  370. ->where('UserID', $UserID)
  371. ->orderBy('CreateTime', 'desc')
  372. ->get();
  373. $ac = self::pickBestCandidate($candidates, $fbclid);
  374. }
  375. if (!$ac && (!empty($FPID) || !empty($FF))) {
  376. $query = DB::table($table);
  377. if (!empty($FPID) && !empty($FF)) {
  378. $query->where(function ($q) use ($FPID, $FF) {
  379. $q->where('FPID', $FPID)->orWhere('FF', $FF);
  380. });
  381. } elseif (!empty($FPID)) {
  382. $query->where('FPID', $FPID);
  383. } else {
  384. $query->where('FF', $FF);
  385. }
  386. $candidates = $query->orderBy('CreateTime', 'desc')->get();
  387. $ac = self::pickBestCandidate($candidates, $fbclid);
  388. if ($ac) {
  389. $updateArr = [];
  390. if (!empty($FPID) && empty($ac->FPID)) $updateArr['FPID'] = $FPID;
  391. if (!empty($FF) && empty($ac->FF)) $updateArr['FF'] = $FF;
  392. if (!empty($UserID) && (empty($ac->UserID) || $ac->UserID == 0)) {
  393. $updateArr['UserID'] = $UserID;
  394. }
  395. if (!empty($updateArr)) {
  396. DB::table($table)->where('ID', $ac->ID)->update($updateArr);
  397. }
  398. }
  399. }
  400. if ($ac) {
  401. $ac=(array)$ac;
  402. if(isset($ac['localStorage'])&&isset($ac['ID'])) {
  403. $ls = json_decode($ac['localStorage'], true);
  404. if (isset($ls['user'])) {
  405. unset($ls['user']);
  406. $ac['localStorage'] = json_encode($ls);
  407. DB::table($table)->where('ID', $ac['ID'])->update(['localStorage' => $ac['localStorage']]);
  408. }
  409. }
  410. return $ac;
  411. }
  412. return null;
  413. }
  414. /**
  415. * bindCookie 成功更新行时:若请求里带有效 _fbc,而库中 Cookie 字段尚无 _fbc,则补写 Cookie。
  416. *
  417. * @param array $updateArr 原 update 数组(UserID/FPID/FF/SPE_KEY)
  418. * @param object $record 当前命中的 AccountCookie 行
  419. * @param string $incoming 客户端上报的 cookie 串
  420. * @return array 合并后的 update 数组
  421. */
  422. protected static function applyFbcCookieBackfillToUpdate(array $updateArr, $record, $incomingCookie)
  423. {
  424. $merged = self::mergeIncomingFbcIntoAccountCookie($record->Cookie ?? '', $incomingCookie);
  425. if ($merged !== null) {
  426. $updateArr['Cookie'] = $merged;
  427. }
  428. return $updateArr;
  429. }
  430. /**
  431. * 请求侧有有效 _fbc,且库中 Cookie 不含 _fbc 时,生成应写入的 Cookie 全文。
  432. * - 库为空:采用客户端完整串(含 _fbp 等)
  433. * - 库有其它键无 _fbc:前置 _fbc=...; 保留原串
  434. *
  435. * @return string|null 需更新时返回新串;否则 null
  436. */
  437. public static function mergeIncomingFbcIntoAccountCookie($storedCookie, $incomingCookie)
  438. {
  439. $incomingCookie = is_string($incomingCookie) ? trim($incomingCookie) : '';
  440. if ($incomingCookie === '') {
  441. return null;
  442. }
  443. if (self::extractFbclid($incomingCookie) === '') {
  444. return null;
  445. }
  446. $storedCookie = trim((string)$storedCookie);
  447. if ($storedCookie !== '' && preg_match('/_fbc\s*=/i', $storedCookie)) {
  448. return null;
  449. }
  450. if (preg_match('/_fbc\s*=\s*([^;]+)/', $incomingCookie, $m)) {
  451. $fbcPair = '_fbc=' . trim($m[1]);
  452. } elseif (preg_match('/\bfb\.\d+\.\d+\.[^;\s]+/', $incomingCookie, $m)) {
  453. $fbcPair = '_fbc=' . $m[0];
  454. } else {
  455. return null;
  456. }
  457. if ($storedCookie === '') {
  458. return $incomingCookie;
  459. }
  460. return $fbcPair . '; ' . $storedCookie;
  461. }
  462. /**
  463. * 从 _fbc cookie 中提取 fbclid。
  464. *
  465. * _fbc 格式:fb.{version}.{timestamp}.{fbclid}
  466. * 例:fb.1.1774133029549.IwZXh0bgNhZW0BMABh...
  467. *
  468. * 支持三种输入:
  469. * - 完整 cookie 字符串(_fbc=fb.1.xxx;_fbp=...)→ 提取 _fbc 值再取第4段
  470. * - 纯 _fbc 值(fb.1.xxx.yyy)→ 直接取第4段
  471. * - 纯 fbclid 字符串 → 原样返回
  472. *
  473. * @param string $cookieStr cookie 字符串
  474. * @return string fbclid,或空串
  475. */
  476. public static function extractFbclid($cookieStr)
  477. {
  478. if (empty($cookieStr)) return '';
  479. $fbc = '';
  480. if (preg_match('/_fbc=([^;]+)/', $cookieStr, $m)) {
  481. $fbc = $m[1];
  482. } elseif (str_starts_with($cookieStr, 'fb.')) {
  483. $fbc = $cookieStr;
  484. }
  485. if (empty($fbc)) return '';
  486. $parts = explode('.', $fbc, 4);
  487. if (count($parts) < 4 || empty($parts[3])) return '';
  488. return $parts[3];
  489. }
  490. /**
  491. * 从多条候选记录中,用 fbclid 匹配最精准的一条,匹配不到则取最新。
  492. * $candidates 必须已按 CreateTime desc 排序。
  493. */
  494. public static function pickBestCandidate($candidates, $fbclid = '')
  495. {
  496. if ($candidates->isEmpty()) return null;
  497. if ($candidates->count() === 1) return $candidates->first();
  498. if (!empty($fbclid)) {
  499. $match = $candidates->first(function ($row) use ($fbclid) {
  500. return !empty($row->Cookie) && str_contains($row->Cookie, $fbclid);
  501. });
  502. if ($match) return $match;
  503. }
  504. return $candidates->first();
  505. }
  506. /** 将一条广告点击数据压入该渠道的最近记录队列(最多保留 50 条)。 */
  507. public static function addRecentsNew($data, $url_sign)
  508. {
  509. $recents = self::getRecentsNew($url_sign);
  510. array_push($recents, $data);
  511. if (count($recents) > 50) {
  512. array_shift($recents);
  513. }
  514. Redis::set("recent_apks_$url_sign", json_encode($recents));
  515. }
  516. public static function setRecentsNew($recents, $url_sign)
  517. {
  518. Redis::set("recent_apks_$url_sign", json_encode($recents));
  519. }
  520. public static function getRecentsNew($url_sign)
  521. {
  522. $recents = [];
  523. if (Redis::exists("recent_apks_$url_sign")) {
  524. $recents = json_decode(Redis::get("recent_apks_$url_sign"), true);
  525. }
  526. if (!isset($recents) || empty($recents)) $recents = [];
  527. return $recents;
  528. }
  529. public const KWAI_EVENT = [
  530. 'EVENT_DOWNLOAD' => 'EVENT_DOWNLOAD',//download
  531. 'EVENT_COMPLETE_REGISTRATION' => 'EVENT_COMPLETE_REGISTRATION',//reg
  532. 'EVENT_PURCHASE' => 'EVENT_PURCHASE',//purchase
  533. 'EVENT_FIRST_DEPOSIT' => 'EVENT_FIRST_DEPOSIT',//d0
  534. 'EVENT_ADD_TO_CART' => 'EVENT_ADD_TO_CART',//
  535. 'EVENT_CONTENT_VIEW' => 'EVENT_CONTENT_VIEW',//
  536. ];
  537. /** 快手 S2S 事件上报:根据 UserID 加载 Cookie,如果是快手渠道则发送事件。 */
  538. public static function kwaiEvent($userid, $event, $event_values = [])
  539. {
  540. // $cookies=['Cookie' => $data['cookie'],
  541. // 'UrlSign' => $data['url_sign'],
  542. // 'Platform' => $data['type']]
  543. $user = self::loadCookie($userid);
  544. /*
  545. * {
  546. CampaignID: '987654321',
  547. adSETID: '123456789',
  548. CreativeID: '123456789',
  549. click_id: 'CCpgibAfpRkSWv9zxgDuFHX71q5u_vMxqmvRClE6enEynSNeQAaSvb3CCcXoqXaPwbYpThJBW5n6-62kmmECfY5He3KFgOJB9YErIJ9cIUk=',
  550. pixel_id: '12345678987654321'
  551. }
  552. */
  553. if (isset($user) && $user['Platform'] == 'kw') {
  554. $params = json_decode($user['Cookie'], true);
  555. self::sendToKwai($params, $event, $event_values);
  556. }
  557. }
  558. /** 向快手广告 API 发送转化事件(生产模式,trackFlag=false)。 */
  559. public static function sendToKwai($params, $event, $event_values = [])
  560. {
  561. if ((isset($params['pixel_id']) && $params['pixel_id'] == '545692252418097195') || (isset($params['kwai_api_id']) && $params['kwai_api_id'] == '545692252418097195')) {
  562. $access_token = '1Wvai0OBGGQlO9eVZIm+wnv1tq5hL9IudwMRdntfms4=';
  563. }
  564. if ((isset($params['pixel_id']) && $params['pixel_id'] == '545692410115539004') || (isset($params['kwai_api_id']) && $params['kwai_api_id'] == '545692410115539004')) {
  565. $access_token = 'cwYDbfB+9lWmVaSBWNxhFUVtL764Or8AzxGVclhG44g=';
  566. }
  567. try {
  568. $url = 'http://www.adsnebula.com/log/common/api';
  569. $data = [
  570. "access_token" => $access_token,
  571. "clickid" => $params['click_id'],
  572. "event_name" => $event,
  573. "is_attributed" => 1,
  574. "mmpcode" => "PL",
  575. "pixelId" => $params['pixel_id'] ?? $params['kwai_api_id'],
  576. "pixelSdkVersion" => "9.9.9",
  577. "properties" => json_encode($event_values),
  578. "testFlag" => false,
  579. "trackFlag" => false
  580. ];
  581. $httpCurl = new HttpCurl();
  582. $result = $httpCurl->curlPost($url, $data, 'json', true);
  583. Util::WriteLog("kwai", $result);
  584. } catch (\Exception $e) {
  585. Util::WriteLog("kwai_error", compact("params", "event", "event_values"));
  586. Util::WriteLog("kwai_error", $e->getMessage() . $e->getTraceAsString());
  587. }
  588. }
  589. /** 向快手广告 API 发送转化事件(测试模式,trackFlag=true)。 */
  590. public static function sendToKwaiTest($params, $event, $event_values = [])
  591. {
  592. if ((isset($params['pixel_id']) && $params['pixel_id'] == '545692252418097195') || (isset($params['kwai_api_id']) && $params['kwai_api_id'] == '545692252418097195')) {
  593. $access_token = '1Wvai0OBGGQlO9eVZIm+wnv1tq5hL9IudwMRdntfms4=';
  594. }
  595. if ((isset($params['pixel_id']) && $params['pixel_id'] == '545692410115539004') || (isset($params['kwai_api_id']) && $params['kwai_api_id'] == '545692410115539004')) {
  596. $access_token = 'cwYDbfB+9lWmVaSBWNxhFUVtL764Or8AzxGVclhG44g=';
  597. }
  598. try {
  599. $url = 'http://www.adsnebula.com/log/common/api';
  600. $data = [
  601. "access_token" => $access_token,
  602. "clickid" => $params['click_id'],
  603. "event_name" => $event,
  604. "is_attributed" => 1,
  605. "mmpcode" => "PL",
  606. "pixelId" => $params['pixel_id'] ?? $params['kwai_api_id'],
  607. "pixelSdkVersion" => "9.9.9",
  608. "properties" => json_encode($event_values),
  609. "testFlag" => false,
  610. "trackFlag" => true
  611. ];
  612. $httpCurl = new HttpCurl();
  613. $result = $httpCurl->curlPost($url, $data, 'json', true);
  614. Util::WriteLog("kwai", $data);
  615. Util::WriteLog("kwai", $result);
  616. } catch (\Exception $e) {
  617. Util::WriteLog("kwai_error", compact("params", "event", "event_values"));
  618. Util::WriteLog("kwai_error", $e->getMessage() . $e->getTraceAsString());
  619. }
  620. }
  621. /** 对老用户设置游戏控分参数(首次设置后不再重复写入)。 */
  622. public static function ControlOldUser($UserID, $Params = '')
  623. {
  624. $openGames = config('games.openKGame');
  625. $query = DB::table('QPTreasureDB.dbo.UserScoreControl')->where('UserID', $UserID)->first();
  626. if (!$query) {
  627. $build_sql = DB::connection('write')->table('QPTreasureDB.dbo.UserScoreControl');
  628. Util::WriteLog('control_old_user', compact("UserID", "Params"));
  629. $data = [
  630. 'ControlScore' => (int)(-40 * NumConfig::NUM_VALUE),
  631. 'EffectiveScore' => 0,
  632. 'ControlKindID' => -1,
  633. 'Remarks' => '',
  634. 'InsertDate' => date('Y-m-d H:i:s'),
  635. 'ControlRadian' => 0
  636. ];
  637. $build_sql->updateOrInsert(['UserID' => $UserID], $data);
  638. foreach ($openGames as $GameID) {
  639. $KindData = [
  640. 'UserID' => $UserID,
  641. 'KindID' => $GameID,
  642. 'ControlRadian' => 50,
  643. 'ControlDate' => date('Y-m-d H:i:s')
  644. ];
  645. DB::connection('write')->table('QPTreasureDB.dbo.UserControlKind')->updateOrInsert(['UserID' => $UserID, 'KindID' => $GameID], $KindData);
  646. }
  647. }
  648. }
  649. }