http = new Client($this->buildGuzzleOptions()); Timer::add(self::TIMER_SECONDS, [$this, 'pushPendingOrders']); } /** * Guzzle 默认校验 HTTPS;Windows 未配置 CA 时会出现 cURL error 60。 * 优先使用 PLAYX_ANGPOW_IMPORT_CACERT 指向 cacert.pem;否则可按环境关闭校验(仅开发)。 */ private function buildGuzzleOptions(): array { $options = [ 'timeout' => 20, 'http_errors' => false, ]; $conf = config('playx.angpow_import'); if (!is_array($conf)) { return $options; } $caFile = $conf['ca_file'] ?? ''; if (is_string($caFile) && $caFile !== '' && is_file($caFile)) { $options['verify'] = $caFile; return $options; } $verifySsl = $conf['verify_ssl'] ?? true; if ($verifySsl === false) { $options['verify'] = false; } return $options; } public function pushPendingOrders(): void { $conf = config('playx.angpow_import'); if (!is_array($conf)) { return; } $baseUrl = $conf['base_url'] ?? ''; $path = $conf['path'] ?? ''; $merchantCode = $conf['merchant_code'] ?? ''; $authKey = $conf['auth_key'] ?? ''; if (!is_string($baseUrl) || $baseUrl === '' || !is_string($path) || $path === '' || !is_string($merchantCode) || $merchantCode === '') { return; } if (!is_string($authKey) || $authKey === '') { return; } $url = rtrim($baseUrl, '/') . $path; $orders = MallOrder::where('type', MallOrder::TYPE_BONUS) ->whereIn('grant_status', [MallOrder::GRANT_NOT_SENT, MallOrder::GRANT_FAILED_RETRYABLE]) ->where('status', MallOrder::STATUS_PENDING) ->where('retry_count', '<', self::MAX_RETRY) ->order('id', 'asc') ->limit(self::BATCH_LIMIT) ->select(); if ($orders->isEmpty()) { return; } $reportDate = strval(time()); $signatureInput = 'merchant_code=' . $merchantCode . '&report_date=' . $reportDate; $signature = $this->buildSignature($signatureInput, $authKey); if ($signature === null) { return; } $payload = [ 'merchant_code' => $merchantCode, 'report_date' => $reportDate, 'angpow' => [], 'currency_visual' => [ [ 'currency' => strval($conf['currency'] ?? 'MYR'), 'visual_name' => strval($conf['visual_name'] ?? 'Angpow'), ], ], ]; $orderIds = []; foreach ($orders as $order) { if (!$order instanceof MallOrder) { continue; } $row = $this->buildAngpowRow($order); if ($row === null) { // 构造失败:直接标为可重试失败 $this->markFailedAttempt($order, 'Build payload failed'); continue; } $payload['angpow'][] = $row; $orderIds[] = $order->id; } if (empty($payload['angpow'])) { return; } // 先标记“已尝试发送”,避免并发重复推送;同时只在这里累加 retry_count(一次推送=一次尝试) $now = time(); foreach ($orders as $order) { if (!$order instanceof MallOrder) { continue; } if (!in_array($order->id, $orderIds, true)) { continue; } $retry = $order->retry_count ?? 0; if (!is_int($retry)) { $retry = is_numeric($retry) ? intval($retry) : 0; } $order->retry_count = $retry + 1; $order->grant_status = MallOrder::GRANT_SENT_PENDING; $order->update_time = $now; $order->save(); } $res = null; $body = ''; try { $res = $this->http->post($url, [ 'headers' => [ 'Content-Type' => 'application/json', 'X-Request-Signature' => $signature, ], 'json' => $payload, ]); $body = strval($res->getBody()); } catch (\Throwable $e) { // 网络/异常:对这一批订单记一次失败尝试 foreach ($orders as $order) { if (!$order instanceof MallOrder) { continue; } $this->markFailedAttempt($order, $e->getMessage()); } return; } $data = json_decode($body, true); if (!is_array($data)) { foreach ($orders as $order) { if (!$order instanceof MallOrder) { continue; } $this->markFailedAttempt($order, 'Invalid response'); } return; } $code = $data['code'] ?? null; $message = $data['message'] ?? ''; $msg = is_string($message) ? $message : 'Request failed'; // 成功:code=0 if ($code === '0' || $code === 0) { MallOrder::whereIn('id', $orderIds)->update([ 'grant_status' => MallOrder::GRANT_ACCEPTED, 'fail_reason' => null, 'update_time' => time(), ]); return; } // 失败:整批视为失败(对方未提供逐条返回) foreach ($orders as $order) { if (!$order instanceof MallOrder) { continue; } $this->markFailedAttempt($order, $msg); } } private function buildAngpowRow(MallOrder $order): ?array { $asset = MallUserAsset::where('playx_user_id', $order->user_id)->find(); if (!$asset) { if (is_string($order->user_id) && ctype_digit($order->user_id)) { $byId = MallUserAsset::where('id', $order->user_id)->find(); if ($byId) { $asset = $byId; } } } if (!$asset || !is_string($asset->playx_user_id ?? null) || strval($asset->playx_user_id) === '') { return null; } $item = null; if ($order->mallItem) { $item = $order->mallItem; } else { $item = MallItem::where('id', $order->mall_item_id)->find(); } if (!$item) { return null; } $createTime = $order->create_time ?? null; if (!is_int($createTime)) { if (is_numeric($createTime)) { $createTime = intval($createTime); } else { $createTime = time(); } } $start = gmdate('Y-m-d\TH:i:s\Z', $createTime); $end = gmdate('Y-m-d\TH:i:s\Z', $createTime + 86400); return [ 'member_login' => strval($asset->playx_user_id), 'start_time' => $start, 'end_time' => $end, 'amount' => $order->amount, 'reward_name' => strval($item->title ?? ''), 'description' => strval($item->description ?? ''), 'member_inbox_message' => 'Congratulations! You received an angpow.', 'category' => strval($item->category ?? ''), 'category_title' => strval($item->category_title ?? ''), 'one_time_turnover' => 'yes', 'multiplier' => $order->multiplier, ]; } private function markFailedAttempt(MallOrder $order, string $reason): void { // retry_count 在“准备发送”阶段已 +1;此处用当前 retry_count 作为 attempt 编号 $retryCount = $order->retry_count ?? 0; $attempt = is_int($retryCount) ? $retryCount : (is_numeric($retryCount) ? intval($retryCount) : 0); if ($attempt <= 0) { $attempt = 1; $order->retry_count = 1; } $prev = $order->fail_reason; $prefix = 'attempt ' . $attempt . ': '; $line = $prefix . $reason; $newReason = $line; if (is_string($prev) && $prev !== '') { $newReason = $prev . "\n" . $line; } $final = $attempt >= self::MAX_RETRY; $order->grant_status = $final ? MallOrder::GRANT_FAILED_FINAL : MallOrder::GRANT_FAILED_RETRYABLE; $order->fail_reason = $newReason; $order->save(); } /** * 生成对方要求的 Base64(HMAC-SHA1) * - 文档中示例 python 会 base64_decode(key) 后参与 hmac * - 生产 key 由 BA 提供,可能是 base64 或 hex;这里做兼容处理 */ private function buildSignature(string $input, string $authKey): ?string { $keyBytes = null; $maybeBase64 = base64_decode($authKey, true); if ($maybeBase64 !== false && $maybeBase64 !== '') { $keyBytes = $maybeBase64; } if ($keyBytes === null) { $isHex = ctype_xdigit($authKey) && (strlen($authKey) % 2 === 0); if ($isHex) { $hex = hex2bin($authKey); if ($hex !== false && $hex !== '') { $keyBytes = $hex; } } } if ($keyBytes === null) { $keyBytes = $authKey; } $raw = hash_hmac('sha1', $input, $keyBytes, true); if (!is_string($raw) || $raw === '') { return null; } return base64_encode($raw); } }