🎯 サーバー実装概要
天気保証サービス統合の基本フロー
既存の予約システムに天気保証機能を追加し、雨天時の全額返金を自動化
💡 実装のポイント
1
決済統合
基本料金 + 保証料で
一括決済処理
→
2
API連携
決済成功後に
LinkTrip APIへ保証データ送信
→
3
ウィジェット表示
予約完了画面で
アプリ連携ウィジェット表示
🔧 技術要件
HTTP通信
POST /api/service/weathercover/create-weathercover
- JSON形式のリクエスト/レスポンス
- API Key認証
- HTTPS必須
データ形式
保証データの構造化送信
- 事業者ID・予約ID
- 金額・日付情報
- 場所・ルール情報
エラー処理
堅牢なエラーハンドリング
- リトライ機能
- タイムアウト設定
- ログ記録
🔄 決済〜API連携の詳細フロー
⚙️ 既存決済システムの改修
改修箇所: 決済処理ロジックに保証料を上乗せ
🔧 必要な修正作業
- 料金計算部分: 基本料金に保証料(基本料金×10%)を加算
- 決済API呼び出し: 合計金額で既存の決済処理を実行
- データベース保存: 決済IDと保証料を既存テーブルに追加保存
- 画面表示: 内訳表示に「天気保証料」項目を追加
例: ゴルフ料金 15,000円 + 保証料 1,500円 = 決済額 16,500円
🌐 LinkTrip API連携の追加
追加実装: 決済成功後に新規でLinkTrip APIを呼び出し
🔧 新規追加コード
- API呼び出し処理: POST /api/service/weathercover/create-weathercover
- 認証ヘッダー: Authorization: Bearer [事業者APIキー]
- 送信データ構築: 予約ID・金額・日付・場所情報をJSON形式で送信
- エラーハンドリング: API失敗時のリトライ・ログ出力処理
📝 実装ポイント: 既存の予約確定処理の直後に追加実装
📄 予約完了画面の修正
改修箇所: 完了画面にアプリ連携ウィジェットを表示
🔧 画面修正作業
- HTMLテンプレート: 予約完了画面にウィジェット用div要素を追加
- JavaScript追加: LinkTripウィジェットSDKの読み込み・初期化
- データ受け渡し: API取得したdocumentIdを画面に渡す
- 条件分岐: 保証付き予約の場合のみウィジェット表示
🎯 実装結果: お客様がボタンをタップしてアプリで返金受け取り可能
⏰ 重要なタイミング
重要
決済確定後の即座な処理: 決済が成功した直後に、失敗する前にLinkTrip APIを呼び出す
推奨
非同期処理での実装: ユーザーの待機時間を短縮し、UX向上を図る
📡 LinkTrip API詳細仕様
基本情報
エンドポイント:
POST /api/service/weathercover/create-weathercover
認証方式:
API Key認証(Authorization ヘッダー)
Content-Type:
application/json
タイムアウト:
30秒(推奨)
📥 リクエストパラメータ
必須パラメータ
| パラメータ | 型 | 説明 | 例 |
|---|---|---|---|
partnerId |
string | 事業者ID(LinkTripより発行) | "gdo" |
totalAmount |
number | 基本料金(円・税込) | 15000 |
fee |
number | 実際に支払った保証料(円・税込) | 1500 |
guaranteeDate |
string | 保証対象日(YYYY-MM-DD形式) | "2025-12-29" |
bookingId |
string | 事業者システムの予約番号 | "BK20251229001" |
任意パラメータ
| パラメータ | 型 | 説明 | デフォルト値 |
|---|---|---|---|
partnerLocationId |
string | 不要(Single方式では使用しません) | null |
📍 場所解決方式の選択
事業者の拠点数・運用方針に応じて、適切な場所解決方式を選択できます:
推奨対象
- 単一拠点事業者(1店舗のゴルフ場等)
- 少数拠点事業者(3-5店舗以下)
- 全拠点で統一的な天気対応をする事業者
特徴
- ✅ 設定が簡単(事前設定のみ)
- ✅ API呼び出し時の追加パラメータ不要
- ✅ 開発工数が少ない
- ⚠️ 全拠点で同じ場所の天気を参照
設定方法
事業者登録時にdefaultLocationIdを設定するだけで利用可能
推奨対象
- 全国チェーン事業者
- 地域密着型多店舗事業者
- 拠点別に正確な天気情報が必要な事業者
特徴
- ✅ 拠点別に最適な天気情報を取得
- ✅ 地域差を考慮した保証判定
- ✅ 柔軟な拠点管理が可能
- ⚠️ 拠点マッピング設定が必要
- ⚠️ API呼び出し時にpartnerLocationId必須
設定方法
事業者の各拠点IDとLinkTripの場所IDのマッピングテーブルを事前に設定
📤 レスポンス仕様
成功レスポンス(201 Created)
{
"success": true,
"data": {
"documentId": "gdo-BK20251229001",
"locationId": "loc_1234567890",
"locationResolution": "mapping",
"guaranteeDate": "2025-12-29",
"totalAmount": 15000,
"fee": 1500
},
"message": "weathercover が正常に作成されました"
}
エラーレスポンス
{
"success": false,
"errorType": "VALIDATION_ERROR",
"errorMessage": "Validation failed: totalAmount must be a non-negative number"
}
💻 言語別実装例
Node.js + Express実装例
const express = require('express');
const fetch = require('node-fetch');
// 天気保証付き予約完了処理
app.post('/complete-booking-with-guarantee', async (req, res) => {
const { bookingId, totalAmount, guaranteeFee, playDate, golfCourseId } = req.body;
try {
// 1. 決済処理(既存のロジック)
const paymentResult = await processPayment({
amount: totalAmount + guaranteeFee,
description: `ゴルフ予約 + 天気保証 (${bookingId})`
});
if (!paymentResult.success) {
return res.status(400).json({
error: 'Payment failed',
details: paymentResult.error
});
}
// 2. LinkTrip API呼び出し
const weathercoverData = {
partnerId: process.env.LINKTRIP_PARTNER_ID,
bookingId: bookingId,
totalAmount: totalAmount,
fee: guaranteeFee,
guaranteeDate: playDate,
partnerLocationId: golfCourseId
};
console.log('Creating weathercover:', weathercoverData);
const apiResponse = await fetch(
`${process.env.LINKTRIP_API_BASE}/api/service/weathercover/create-weathercover`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${process.env.LINKTRIP_API_KEY}`
},
body: JSON.stringify(weathercoverData),
timeout: 30000
}
);
if (!apiResponse.ok) {
const errorData = await apiResponse.json();
throw new Error(`API error: ${errorData.errorMessage || apiResponse.status}`);
}
const result = await apiResponse.json();
console.log('Weathercover created successfully:', result.data);
// 3. 成功時の処理
res.redirect(`/booking-complete?id=${bookingId}&guarantee=${result.data.documentId}`);
} catch (error) {
console.error('Weather guarantee creation failed:', error);
// エラーハンドリング:保証作成失敗でも予約は完了扱い
res.redirect(`/booking-complete?id=${bookingId}&guarantee_error=true`);
}
});
PHP(WordPress)実装例
<?php
/**
* 天気保証付き予約完了処理
*/
function complete_booking_with_guarantee($booking_data) {
try {
// 1. 決済処理
$payment_result = process_payment([
'amount' => $booking_data['total_amount'] + $booking_data['guarantee_fee'],
'description' => sprintf('ゴルフ予約 + 天気保証 (%s)', $booking_data['booking_id'])
]);
if (!$payment_result['success']) {
throw new Exception('Payment failed: ' . $payment_result['error']);
}
// 2. LinkTrip API呼び出し
$weathercover_data = [
'partnerId' => get_option('linktrip_partner_id'),
'bookingId' => $booking_data['booking_id'],
'totalAmount' => intval($booking_data['total_amount']),
'fee' => intval($booking_data['guarantee_fee']),
'guaranteeDate' => $booking_data['play_date'],
'partnerLocationId' => $booking_data['golf_course_id']
];
error_log('Creating weathercover: ' . json_encode($weathercover_data));
$response = wp_remote_post(
get_option('linktrip_api_base') . '/api/service/weathercover/create-weathercover',
[
'headers' => [
'Content-Type' => 'application/json',
'Authorization' => 'Bearer ' . get_option('linktrip_api_key')
],
'body' => json_encode($weathercover_data),
'timeout' => 30
]
);
if (is_wp_error($response)) {
throw new Exception('API request failed: ' . $response->get_error_message());
}
$status_code = wp_remote_retrieve_response_code($response);
if ($status_code !== 201) {
$error_body = json_decode(wp_remote_retrieve_body($response), true);
throw new Exception('API error: ' . ($error_body['errorMessage'] ?? $status_code));
}
$result = json_decode(wp_remote_retrieve_body($response), true);
error_log('Weathercover created successfully: ' . json_encode($result['data']));
// 3. 成功時の処理
return [
'success' => true,
'document_id' => $result['data']['documentId'],
'booking_id' => $booking_data['booking_id']
];
} catch (Exception $e) {
error_log('Weather guarantee creation failed: ' . $e->getMessage());
// エラーハンドリング:保証作成失敗でも予約は完了扱い
return [
'success' => false,
'error' => $e->getMessage(),
'booking_id' => $booking_data['booking_id']
];
}
}
?>
Python/Django実装例
import requests
import logging
from django.conf import settings
from django.http import JsonResponse
from django.shortcuts import redirect
logger = logging.getLogger(__name__)
def complete_booking_with_guarantee(request):
"""天気保証付き予約完了処理"""
try:
booking_data = request.POST
# 1. 決済処理(既存のロジック)
payment_result = process_payment({
'amount': int(booking_data['total_amount']) + int(booking_data['guarantee_fee']),
'description': f"ゴルフ予約 + 天気保証 ({booking_data['booking_id']})"
})
if not payment_result['success']:
raise ValueError(f"Payment failed: {payment_result['error']}")
# 2. LinkTrip API呼び出し
weathercover_data = {
'partnerId': settings.LINKTRIP_PARTNER_ID,
'bookingId': booking_data['booking_id'],
'totalAmount': int(booking_data['total_amount']),
'fee': int(booking_data['guarantee_fee']),
'guaranteeDate': booking_data['play_date'],
'partnerLocationId': booking_data.get('golf_course_id')
}
logger.info(f'Creating weathercover: {weathercover_data}')
response = requests.post(
f"{settings.LINKTRIP_API_BASE}/api/service/weathercover/create-weathercover",
headers={
'Content-Type': 'application/json',
'Authorization': f'Bearer {settings.LINKTRIP_API_KEY}'
},
json=weathercover_data,
timeout=30
)
response.raise_for_status()
result = response.json()
if not result['success']:
raise ValueError(f"API returned error: {result.get('errorMessage', 'Unknown error')}")
logger.info(f'Weathercover created successfully: {result["data"]}')
# 3. 成功時の処理
return redirect(f'/booking-complete?id={booking_data["booking_id"]}&guarantee={result["data"]["documentId"]}')
except Exception as e:
logger.error(f'Weather guarantee creation failed: {e}')
# エラーハンドリング:保証作成失敗でも予約は完了扱い
return redirect(f'/booking-complete?id={booking_data.get("booking_id", "unknown")}&guarantee_error=true')
Ruby on Rails実装例
require 'net/http'
require 'json'
class BookingController < ApplicationController
# 天気保証付き予約完了処理
def complete_with_guarantee
begin
# 1. 決済処理(既存のロジック)
payment_result = process_payment(
amount: params[:total_amount].to_i + params[:guarantee_fee].to_i,
description: "ゴルフ予約 + 天気保証 (#{params[:booking_id]})"
)
unless payment_result[:success]
raise StandardError, "Payment failed: #{payment_result[:error]}"
end
# 2. LinkTrip API呼び出し
weathercover_data = {
partnerId: Rails.application.credentials.linktrip[:partner_id],
bookingId: params[:booking_id],
totalAmount: params[:total_amount].to_i,
fee: params[:guarantee_fee].to_i,
guaranteeDate: params[:play_date],
partnerLocationId: params[:golf_course_id]
}
Rails.logger.info "Creating weathercover: #{weathercover_data}"
uri = URI("#{Rails.application.credentials.linktrip[:api_base]}/api/service/weathercover/create-weathercover")
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true
http.read_timeout = 30
request = Net::HTTP::Post.new(uri)
request['Content-Type'] = 'application/json'
request['Authorization'] = "Bearer #{Rails.application.credentials.linktrip[:api_key]}"
request.body = weathercover_data.to_json
response = http.request(request)
unless response.code == '201'
error_data = JSON.parse(response.body) rescue {}
raise StandardError, "API error: #{error_data['errorMessage'] || response.code}"
end
result = JSON.parse(response.body)
Rails.logger.info "Weathercover created successfully: #{result['data']}"
# 3. 成功時の処理
redirect_to booking_complete_path(
id: params[:booking_id],
guarantee: result['data']['documentId']
)
rescue => e
Rails.logger.error "Weather guarantee creation failed: #{e.message}"
# エラーハンドリング:保証作成失敗でも予約は完了扱い
redirect_to booking_complete_path(
id: params[:booking_id] || 'unknown',
guarantee_error: true
)
end
end
private
def process_payment(amount:, description:)
# 既存の決済処理ロジック
# 実装は事業者の決済システムに依存
{ success: true }
end
end
⚠️ エラー処理とベストプラクティス
🚨 エラータイプと対処方法
| エラータイプ | HTTPステータス | 原因 | 対処方法 | リトライ |
|---|---|---|---|---|
VALIDATION_ERROR |
400 | 必須パラメータ不足・形式エラー | リクエストデータを修正して再送信 | ❌ 不要 |
PARTNER_NOT_FOUND |
404 | 事業者IDが存在しない | 正しいpartnerIdを設定 | ❌ 不要 |
RULE_NOT_FOUND |
400 | 保証ルールが存在しない | partnerIdに紐づく保証ルール設定を確認 | ❌ 不要 |
DUPLICATE_DOCUMENT |
409 | 同じ予約IDで既に作成済み | 重複チェック処理を追加 | ❌ 不要 |
LOCATION_RESOLUTION_FAILED |
400 | 場所IDの解決に失敗 | partnerLocationIdを確認 | ❌ 不要 |
SERVER_ERROR |
500 | サーバー内部エラー | 時間をおいて再実行 | ✅ 推奨 |
| Network Timeout | - | ネットワークの問題 | タイムアウト設定を調整 | ✅ 推奨 |
🎯 実装のベストプラクティス
リトライ処理
ネットワークエラーや5xxエラー時は指数バックオフでリトライ
// リトライロジックの例
const maxRetries = 3;
const baseDelay = 1000; // 1秒
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
const response = await callLinktripAPI(data);
return response; // 成功時は即座に返す
} catch (error) {
if (attempt === maxRetries || !shouldRetry(error)) {
throw error; // 最終試行または非リトライエラー
}
const delay = baseDelay * Math.pow(2, attempt - 1);
await new Promise(resolve => setTimeout(resolve, delay));
}
}
タイムアウト設定
30秒のタイムアウトを設定し、ユーザー体験を向上
推奨設定:
- 接続タイムアウト: 10秒
- 読み込みタイムアウト: 30秒
- 合計タイムアウト: 35秒
ログ記録
API呼び出しの成功・失敗を必ずログに記録
// ログ記録の例
console.log('[LINKTRIP_API]', {
action: 'create_weathercover',
partnerId: data.partnerId,
bookingId: data.bookingId,
status: 'success',
documentId: result.data.documentId,
timestamp: new Date().toISOString()
});
重複防止
同一予約IDでの重複呼び出しを防ぐチェック機能
// 重複チェックの例
const existingGuarantee = await checkExistingGuarantee(bookingId);
if (existingGuarantee) {
console.log('Guarantee already exists:', existingGuarantee);
return existingGuarantee; // 既存データを返す
}
グレースフル フォールバック
API失敗時でも予約処理は継続し、顧客体験を保持
フォールバック戦略:
- 予約完了は正常に表示
- 天気保証は「処理中」として表示
- 後でバッチ処理で再試行
- 顧客に適切な説明を提供
💻 包括的なエラーハンドリング実装例
async function createWeathercoverWithRetry(weathercoverData, maxRetries = 3) {
const baseDelay = 1000;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
const response = await fetch(API_ENDPOINT, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${API_KEY}`
},
body: JSON.stringify(weathercoverData),
timeout: 30000
});
// HTTPエラーをチェック
if (!response.ok) {
const errorData = await response.json();
// リトライすべきでないエラー
if ([400, 404, 409].includes(response.status)) {
throw new NonRetryableError(errorData.errorMessage, errorData.errorType);
}
// リトライ可能なエラー
throw new RetryableError(errorData.errorMessage, response.status);
}
const result = await response.json();
// 成功ログ
logger.info('Weathercover created successfully', {
documentId: result.data.documentId,
attempt: attempt
});
return result;
} catch (error) {
logger.error('Weathercover creation failed', {
error: error.message,
attempt: attempt,
data: weathercoverData
});
// 最終試行または非リトライエラー
if (attempt === maxRetries || error instanceof NonRetryableError) {
throw error;
}
// 指数バックオフ
const delay = baseDelay * Math.pow(2, attempt - 1);
logger.info(`Retrying in ${delay}ms (attempt ${attempt + 1}/${maxRetries})`);
await new Promise(resolve => setTimeout(resolve, delay));
}
}
}
class NonRetryableError extends Error {
constructor(message, type) {
super(message);
this.name = 'NonRetryableError';
this.type = type;
}
}
class RetryableError extends Error {
constructor(message, status) {
super(message);
this.name = 'RetryableError';
this.status = status;
}
}
🧪 テスト方法
🔍 テスト項目チェックリスト
基本機能テスト
バリデーションテスト
エラーハンドリングテスト
🎯 テストデータ例
成功パターン
{
"partnerId": "test-partner",
"bookingId": "TEST_BOOKING_20251229_001",
"totalAmount": 15000,
"fee": 1500,
"guaranteeDate": "2025-12-29",
"partnerLocationId": "test_golf_course_001",
"originalFee": 2000,
"usedPoint": 500
}
エラーパターン(バリデーション)
{
"partnerId": "test-partner",
// bookingId が不足
"totalAmount": -100, // 負の値
"fee": 1500,
"guaranteeDate": "2023-12-29", // 過去の日付
"partnerLocationId": "test_golf_course_001"
}
🔧 テスト環境の構築
1
開発環境でのAPI接続
テスト用のpartnerId・API Keyを使用
// 環境変数設定例
LINKTRIP_API_BASE=https://staging-api.linktrip.co.jp
LINKTRIP_PARTNER_ID=test-partner
LINKTRIP_API_KEY=test_key_1234567890
2
モックサーバーの利用
開発初期段階でのテスト用モック
// Jest + nock を使ったモックテスト例
const nock = require('nock');
nock('https://api.linktrip.co.jp')
.post('/api/service/weathercover/create-weathercover')
.reply(201, {
success: true,
data: { documentId: 'mock-document-id' }
});
3
統合テスト
実際のAPI環境での動作確認
本番前の最終確認として、ステージング環境で実施