All checks were successful
Deploy main / deploy (push) Successful in 23s
- SHJ-4B: 定期契約更新バッチ処理
428 lines
16 KiB
PHP
428 lines
16 KiB
PHP
<?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');
|
||
}
|
||
}
|
||
|