getContent(); $md5Hash = md5($raw); // IP白名单检查(如果配置了) if (!$this->validateClientIp($request->ip())) { Log::warning('SHJ-4A IP白名单验证失败', [ 'ip' => $request->ip(), 'content_length' => strlen($raw), ]); return $this->errorResponse('Unauthorized IP', 403); } // 事前にログ記録(サイズ上限に注意) Log::info('SHJ-4A Wellnet PUSH received', [ 'length' => strlen($raw), 'content_type' => $request->header('Content-Type'), 'ip' => $request->ip(), 'md5_hash' => $md5Hash, ]); // 共通バッチログ: start $batch = BatchLog::createBatchLog( 'shj4a', BatchLog::STATUS_START, [ 'ip' => $request->ip(), 'content_type' => $request->header('Content-Type'), 'content_length' => strlen($raw), 'md5_hash' => $md5Hash, ], 'SHJ-4A Wellnet PUSH start' ); try { // 【処理1】幂等性检查 - MD5重复检查 $existingByMd5 = SettlementTransaction::where('md5_string', $md5Hash)->first(); if ($existingByMd5) { Log::info('SHJ-4A 幂等性: MD5重复检测', [ 'md5_hash' => $md5Hash, 'existing_id' => $existingByMd5->settlement_transaction_id, ]); $batch->update([ 'status' => BatchLog::STATUS_SUCCESS, 'end_time' => now(), 'message' => 'SHJ-4A 幂等性: MD5重复,直接返回成功', 'success_count' => 0, // 幂等返回不计入成功数 ]); return $this->successResponse('処理済み(幂等性)'); } // 【処理2】SOAP/XML解析 $xml = @simplexml_load_string($raw); if (!$xml) { throw new \RuntimeException('Invalid XML/SOAP payload'); } // Body 以下の最初の要素を取得 $nsBody = $xml->children('http://schemas.xmlsoap.org/soap/envelope/')->Body ?? null; $payloadNode = $nsBody ? current($nsBody->children()) : $xml; // SOAPでなければ素のXML想定 // XML -> 配列化 $payloadArray = json_decode(json_encode($payloadNode), true) ?? []; // 【処理3】データ抽出と正規化 $data = $this->extractSettlementData($payloadArray, $md5Hash); // 【処理4】必須フィールド検証 $this->validateRequiredFields($data); // 【処理5】複合キー重复检查(contract_payment_number + pay_date + settlement_amount) $existingByComposite = $this->findExistingByCompositeKey($data); if ($existingByComposite) { Log::info('SHJ-4A 幂等性: 複合キー重复検出', [ 'contract_payment_number' => $data['contract_payment_number'], 'pay_date' => $data['pay_date'], 'settlement_amount' => $data['settlement_amount'], 'existing_id' => $existingByComposite->settlement_transaction_id, ]); $batch->update([ 'status' => BatchLog::STATUS_SUCCESS, 'end_time' => now(), 'message' => 'SHJ-4A 幂等性: 複合キー重复,直接返回成功', 'success_count' => 0, ]); return $this->successResponse('処理済み(幂等性)'); } // 【処理6】データベース取込と関連処理 $settlementId = null; DB::transaction(function() use ($data, $batch, &$settlementId) { // 決済トランザクション登録 $settlement = SettlementTransaction::create($data); $settlementId = $settlement->settlement_transaction_id; // 契約テーブルの軽微な更新(SHJ-4Bで正式更新) RegularContract::where('contract_payment_number', $data['contract_payment_number']) ->update(['contract_updated_at' => now()]); // バッチログ成功更新 $batch->update([ 'status' => BatchLog::STATUS_SUCCESS, 'end_time' => now(), 'message' => 'SHJ-4A Wellnet PUSH stored successfully', 'success_count' => 1, 'parameters' => json_encode([ 'settlement_transaction_id' => $settlementId, 'contract_payment_number' => $data['contract_payment_number'], 'settlement_amount' => $data['settlement_amount'], ]), ]); Log::info('SHJ-4A 決済トランザクション登録成功', [ 'settlement_transaction_id' => $settlementId, 'contract_payment_number' => $data['contract_payment_number'], 'settlement_amount' => $data['settlement_amount'], ]); }); // 【処理7】SHJ-4B用キュージョブ投入 try { $jobContext = [ 'contract_payment_number' => $data['contract_payment_number'], 'settlement_amount' => $data['settlement_amount'], 'pay_date' => $data['pay_date'], 'pay_code' => $data['pay_code'], 'triggered_by' => 'shj4a_webhook', 'triggered_at' => $startedAt->toISOString(), ]; ProcessSettlementJob::dispatch($settlementId, $jobContext); Log::info('SHJ-4A ProcessSettlementJob投入成功', [ 'settlement_transaction_id' => $settlementId, 'job_context' => $jobContext, ]); } catch (\Throwable $jobError) { // キュー投入失敗は警告レベル(メイン処理は成功済み) Log::warning('SHJ-4A ProcessSettlementJob投入失敗', [ 'settlement_transaction_id' => $settlementId, 'error' => $jobError->getMessage(), 'note' => '兜底巡検で処理される予定', ]); } return $this->successResponse(); } catch (\Throwable $e) { Log::error('SHJ-4A error', [ 'error' => $e->getMessage(), 'trace' => $e->getTraceAsString(), 'md5_hash' => $md5Hash, ]); if (isset($batch)) { $batch->update([ 'status' => BatchLog::STATUS_ERROR, 'end_time' => now(), 'message' => 'SHJ-4A failed: ' . $e->getMessage(), 'error_details' => $e->getTraceAsString(), 'error_count' => 1, ]); } return $this->errorResponse($e->getMessage()); } } /** * IP白名单验证 * * @param string $clientIp * @return bool */ private function validateClientIp(string $clientIp): bool { $whitelist = config('services.wellnet.ip_whitelist', ''); if (empty($whitelist)) { return true; // 白名单为空时不验证 } $allowedIps = array_map('trim', explode(',', $whitelist)); foreach ($allowedIps as $allowedIp) { if (strpos($allowedIp, '/') !== false) { // CIDR記法対応 if ($this->ipInRange($clientIp, $allowedIp)) { return true; } } else { // 直接IP比較 if ($clientIp === $allowedIp) { return true; } } } return false; } /** * CIDR範囲でのIP检查 * * @param string $ip * @param string $range * @return bool */ private function ipInRange(string $ip, string $range): bool { list($subnet, $bits) = explode('/', $range); $ip = ip2long($ip); $subnet = ip2long($subnet); $mask = -1 << (32 - $bits); $subnet &= $mask; # nb: in case the supplied subnet wasn't correctly aligned return ($ip & $mask) == $subnet; } /** * 決済データの抽出と正規化 * * @param array $payloadArray * @param string $md5Hash * @return array */ private function extractSettlementData(array $payloadArray, string $md5Hash): array { // inData/Result系の取り出し(キー名差異に寛容) $first = function(array $arr, array $keys, $default = null) { foreach ($keys as $k) { if (isset($arr[$k])) return is_array($arr[$k]) ? $arr[$k] : (string)$arr[$k]; } return $default; }; $flat = $payloadArray; // よくある入れ子: { YoyakuNyukin: { inData: {...} } } / { YoyakuNyukinResponse: { YoyakuNyukinResult: {...} } } foreach (['inData','YoyakuSyunoBarCodeResult','YoyakuNyukinResult','YoyakuSyunoETicketResult'] as $k) { if (isset($flat[$k]) && is_array($flat[$k])) { $flat = $flat[$k]; } } $data = [ 'pay_code' => $first($flat, ['NyukinPayCode','SyunoPayCode','BcPayCode']), 'contract_payment_number' => $first($flat, ['NyukinRecvNum','SyunoRecvNum','RecvNum','contract_payment_number']), 'corp_code' => $first($flat, ['NyukinCorpCode','SyunoCorpCode','BcCorpCode','CorpCode']), 'mms_date' => $first($flat, ['NyukinReferDate','SyunoMMSNo','MmsDate']), 'cvs_code' => $first($flat, ['NyukinCvsCode','CvsCode']), 'shop_code' => $first($flat, ['NyukinShopCode','ShopCode']), 'pay_date' => $first($flat, ['NyukinPaidDate','PaidDate']), 'settlement_amount' => $first($flat, ['NyukinPaidAmount','SyunoPayAmount','PaidAmount']), 'stamp_flag' => $first($flat, ['NyukinInshiFlag','InshiFlag']), 'status' => 'received', 'md5_string' => $md5Hash, ]; // データ正規化処理 $data = $this->normalizeSettlementData($data); return $data; } /** * 決済データの正規化 * * @param array $data * @return array */ private function normalizeSettlementData(array $data): array { // 金額を数値化(非負数) if (!empty($data['settlement_amount'])) { $amount = preg_replace('/[^\d.]/', '', $data['settlement_amount']); $data['settlement_amount'] = max(0, (float)$amount); } else { $data['settlement_amount'] = null; } // 支払日時の正規化 if (!empty($data['pay_date'])) { try { $data['pay_date'] = Carbon::parse($data['pay_date'])->format('Y-m-d H:i:s'); } catch (\Throwable $e) { Log::warning('SHJ-4A 支払日時解析失敗', [ 'original_pay_date' => $data['pay_date'], 'error' => $e->getMessage(), ]); $data['pay_date'] = null; } } // 文字列フィールドのトリム $stringFields = ['pay_code', 'contract_payment_number', 'corp_code', 'mms_date', 'cvs_code', 'shop_code', 'stamp_flag']; foreach ($stringFields as $field) { if (isset($data[$field])) { $data[$field] = trim($data[$field]) ?: null; } } return $data; } /** * 必須フィールドの検証 * * @param array $data * @throws \RuntimeException */ private function validateRequiredFields(array $data): void { // 必須フィールドのチェック if (empty($data['contract_payment_number'])) { throw new \RuntimeException('必須フィールドが不足: contract_payment_number (RecvNum)'); } if (!isset($data['settlement_amount']) || $data['settlement_amount'] === null) { throw new \RuntimeException('必須フィールドが不足: settlement_amount'); } if (empty($data['pay_date'])) { throw new \RuntimeException('必須フィールドが不足: pay_date'); } } /** * 複合キーによる既存レコード検索 * * @param array $data * @return SettlementTransaction|null */ private function findExistingByCompositeKey(array $data): ?SettlementTransaction { return SettlementTransaction::where('contract_payment_number', $data['contract_payment_number']) ->where('pay_date', $data['pay_date']) ->where('settlement_amount', $data['settlement_amount']) ->first(); } /** * 成功レスポンスの生成 * * @param string $message * @return \Illuminate\Http\Response */ private function successResponse(string $message = '正常処理'): \Illuminate\Http\Response { $responseFormat = config('services.wellnet.response_format', 'json'); if ($responseFormat === 'soap') { return $this->soapResponse(0, $message); } else { return response()->json(['result' => 0, 'message' => $message]); } } /** * エラーレスポンスの生成 * * @param string $message * @param int $httpCode * @return \Illuminate\Http\Response */ private function errorResponse(string $message, int $httpCode = 500): \Illuminate\Http\Response { $responseFormat = config('services.wellnet.response_format', 'json'); if ($responseFormat === 'soap') { return $this->soapResponse(1, $message, $httpCode); } else { $resultCode = ($httpCode >= 500) ? 1 : 2; // サーバーエラー:1, クライアントエラー:2 return response()->json(['result' => $resultCode, 'error' => $message], $httpCode); } } /** * SOAP形式のレスポンス生成 * * @param int $resultCode * @param string $message * @param int $httpCode * @return \Illuminate\Http\Response */ private function soapResponse(int $resultCode, string $message, int $httpCode = 200): \Illuminate\Http\Response { $soapEnvelope = '' . '' . '' . '' . '' . htmlspecialchars($resultCode) . '' . '' . htmlspecialchars($message) . '' . '' . '' . ''; return response($soapEnvelope, $httpCode) ->header('Content-Type', 'text/xml; charset=utf-8'); } }