krgm.so-manager-dev.com/app/Http/Controllers/Webhook/WellnetController.php
Your Name dc86028777
All checks were successful
Deploy main / deploy (push) Successful in 23s
- SHJ-4A: Wellnet決済PUSH受信処理
- SHJ-4B: 定期契約更新バッチ処理
2025-09-12 19:24:55 +09:00

428 lines
16 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace App\Http\Controllers\Webhook;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Queue;
use App\Models\Batch\BatchLog;
use App\Models\SettlementTransaction;
use App\Models\RegularContract;
use App\Jobs\ProcessSettlementJob;
use Carbon\Carbon;
class WellnetController extends Controller
{
/**
* Wellnet PUSH 受信 (SHJ-4A)
* 受け取った SOAP/XML を解析し、settlement_transaction に登録。
* 幂等性チェック、データ検証、キュー投入を含む完全な処理を実行。
*/
public function receive(Request $request)
{
$startedAt = now();
$raw = $request->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 = '<?xml version="1.0" encoding="utf-8"?>'
. '<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">'
. '<soap:Body>'
. '<WellnetPushResponse>'
. '<Result>' . htmlspecialchars($resultCode) . '</Result>'
. '<Message>' . htmlspecialchars($message) . '</Message>'
. '</WellnetPushResponse>'
. '</soap:Body>'
. '</soap:Envelope>';
return response($soapEnvelope, $httpCode)
->header('Content-Type', 'text/xml; charset=utf-8');
}
}