SPIRAL WebToolsの問い合わせフォームから企業情報を受け付けた後、Google Driveに企業ごとの作業フォルダを手作業で用意していませんか。
本記事では、問い合わせDBへの登録をきっかけにPHPを非同期実行し、Google Driveへ「SPIRAL / 企業名」の2階層を自動作成する方法をご紹介します。Google Workspaceの共有ドライブと、個人Googleアカウントのマイドライブを定数で切り替えられます。
作成したフォルダIDとURLは問い合わせDBへ書き戻すため、問い合わせレコードから企業フォルダを追跡できます。
注意点
・ PHP実行アクションは非同期です。問い合わせ登録の完了とGoogle Driveのフォルダ作成完了には時間差が生じる場合があります。
・ DBトリガは単一レコード操作を起点に動作します。レコードの一括登録・一括編集・一括削除では発動しません。
・ PHP実行アクションは30秒で実行時間の上限に達します。本サンプルでは外部通信ごとに短いタイムアウトを設定しています。
・ PHP実行アクションではPHP環境変数を利用できません。OAuth更新トークンなどの秘密情報はSPIRAL上のPHPへ設定し、PHPを編集できるユーザーを最小限にしてください。
・ 本サンプルは選択したDriveのフォルダ一覧を取得し、フォルダ名と親フォルダIDをPHP側で比較します。ファイル数が非常に多い場合は、30秒の実行時間上限に注意してください。
・ SPIRAL WebToolsではサービスアカウント用JWTを署名する機能が制限されているため、本サンプルはOAuth担当ユーザーから取得した更新トークンを使用します。
・ Google Workspace組織そのものをOAuth主体にする「親アカウントOAuth」はありません。管理者アカウントを使っても、その管理者ユーザーとしての認証になります。
・ 共有ドライブモードでは、Workspaceの全メンバーを含むGoogleグループを共有ドライブへ追加し、OAuth担当ユーザーには投稿者以上を付与します。作成されたフォルダは個人ではなく共有ドライブに属します。
・ Google Driveは同じ親フォルダの下に同名フォルダを作成できます。本サンプルは作成前に同名フォルダを検索しますが、完全な同時実行時の重複を原子的に防ぐものではありません。
・ 共有ドライブの本番運用ではAudienceをInternalにします。個人マイドライブでの確認はExternal / Testingを使う一時的なテストです。
・ Google OAuthの更新トークンに無期限保証はありません。長期間問い合わせがない場合にも備え、月次のスケジュールトリガでOAuthとDrive作成先を監視します。
仕様の詳細は、DBトリガのPHP実行アクション、PHPで使用できない機能の一覧、Google OAuth 2.0をご確認ください。
実装の概要
2. 問い合わせDBの登録トリガが、PHP実行アクションを非同期で起動します。
3. PHPがGoogle OAuthの更新トークンを短期アクセストークンへ交換します。
4. Drive APIで設定した共有ドライブまたはマイドライブを検索し、SPIRALフォルダがなければ作成します。
5. SPIRALフォルダの直下から企業名と同じフォルダを検索し、なければ作成します。
6. フォルダID、URL、成否、処理日時をSPIRAL REST APIで問い合わせレコードへ書き戻します。
$SPIRAL->getRecord()の
item配下にあります。作成済みの
driveFolderIdがある場合は処理を終了し、手動再実行による二重作成を防ぎます。
事前準備
問い合わせDBのフィールド
| 表示名 | 識別名 | タイプ | 用途 |
|---|---|---|---|
| 企業名 | companyName | テキスト(必須) | Google Driveの企業フォルダ名 |
| DriveフォルダID | driveFolderId | テキスト | 作成または再利用したフォルダID |
| DriveフォルダURL | driveFolderUrl | テキスト | Google Driveでフォルダを開くURL |
| Drive連携状態 | driveFolderStatus | セレクト | ラベルID 1:成功、2:エラー |
| Drive連携メッセージ | driveFolderMessage | テキストエリア | 新規作成・再利用・エラー内容 |
| Drive連携処理日時 | driveFolderProcessedAt | 日時 | 処理完了日時 |
フィールドの正式な仕様は、フィールドタイプリファレンスをご確認ください。
必要な認証情報
| 設定値 | 用途 | 管理上の注意 |
|---|---|---|
| SPIRAL APIキー | 問い合わせレコードへ結果を書き戻す | 専用APIエージェントと最小権限のアプリロールを使用 |
| SPIRALアプリID・DBID | 書き戻し先を特定する | テスト環境と本番環境で値を分ける |
| Google OAuthクライアントID | Google OAuthのクライアントを識別する | 専用のGoogle Cloudプロジェクトで管理 |
| Googleクライアントシークレット | 更新トークン交換時のクライアント認証 | 記事・Git・実行ログへ記録しない |
| Google更新トークン | ユーザー不在時にアクセストークンを取得する | OAuth担当ユーザーで発行し、失効時の再認証手順を用意 |
| Driveモード | 共有ドライブとマイドライブを切り替える | 共有ドライブはtrue、マイドライブはfalse |
| 共有ドライブID | 共有ドライブモードの検索・作成対象を固定する | マイドライブモードでは使用しない |
| Google Drive作成先ID | SPIRALフォルダを作る基準位置 | 共有ドライブ直下は共有ドライブID、マイドライブは既存テストフォルダID |
設定方法
STEP 1:問い合わせDBを準備する
設定内容:問い合わせDBに、企業名とDrive連携結果を管理するフィールドを追加します。Drive連携結果のフィールドは問い合わせフォームの入力項目に含めません。公式ドキュメント
設定後の状態:問い合わせレコードに企業名を保存でき、Google Driveの処理結果を書き戻せる状態です。
STEP 2:Google Drive APIとOAuthを準備する
設定内容:本番はWorkspaceの共有ドライブ、個人環境での確認はマイドライブを選択します。共有ドライブではWorkspaceの全メンバーを含むGoogleグループを追加し、OAuth担当ユーザーを投稿者以上にします。マイドライブではOAuth担当ユーザーが既存のテスト作成先フォルダを用意します。Google Drive APIを有効にし、OAuth担当ユーザーからオフラインアクセス用の更新トークンを取得します。共有ドライブへGoogleグループを追加する、Google Auth Platformの設定、オフラインアクセス
設定後の状態:クライアントID、クライアントシークレット、更新トークンが揃い、PHPからユーザー不在でDrive APIを呼べる状態です。
https://www.googleapis.com/auth/driveを追加します。次に「Clients」でWeb applicationのOAuthクライアントを作成し、承認済みリダイレクトURIへ
https://developers.google.com/oauthplaygroundを登録します。
| OAuth Playgroundの設定 | 指定する値 | 目的 |
|---|---|---|
| Use your own OAuth credentials | 有効 | 作成したOAuthクライアントで更新トークンを発行する |
| OAuth flow | Server-side | 認可コードをトークンへ交換する |
| Access type | Offline | ユーザー不在時に使う更新トークンを取得する |
| Force prompt | Consent Screen | 同意画面を明示的に表示する |
| OAuth Client ID | 作成したクライアントID | 対象CloudプロジェクトのOAuthアプリを使用する |
| OAuth Client secret | 作成したクライアントシークレット | 認可コード交換時にOAuthクライアントを認証する |
2. 「Step 1 Select & authorize APIs」の「Input your own scopes」へ
https://www.googleapis.com/auth/driveを入力し、「Authorize APIs」を選択します。
3. Googleアカウントの選択画面では、共有ドライブモードなら投稿者以上で参加するOAuth担当ユーザー、マイドライブモードならテスト用個人アカウントを選びます。
4. 同意画面に表示されるアプリ名とDrive権限を確認して続行します。この承認により、PHPはOAuth担当ユーザー本人の権限範囲でDrive APIを利用します。
5. Playgroundへ戻ったら「Step 2 Exchange authorization code for tokens」の「Exchange authorization code for tokens」を選択します。
6. 表示された「Refresh token」を控えます。「Access token」は短期トークンのため、SPIRALへ設定する値ではありません。
7. クライアントIDを
GOOGLE_CLIENT_ID、クライアントシークレットを
GOOGLE_CLIENT_SECRET、Refresh tokenを
GOOGLE_REFRESH_TOKENへ設定します。
8. 更新トークン取得後、Playgroundを今後使用しない場合は、OAuthクライアントの承認済みリダイレクトURIからPlaygroundのURIを外します。再発行時は一時的に戻します。
https://www.googleapis.com/auth/driveを使用します。このスコープは制限付きです。共有ドライブモードでは
corpora=driveと
driveIdで対象を固定し、マイドライブモードでは共有ドライブ専用パラメータを送信せず、設定した親フォルダIDの配下だけを作成対象にします。Drive APIの認証スコープ
STEP 3:SPIRAL APIの最小権限を用意する
設定内容:専用APIエージェントを用意し、問い合わせDBのレコード閲覧とDrive連携結果フィールドの変更だけを許可するアプリロールを設定します。SPIRAL WebTools APIリファレンス
設定後の状態:PHPが、企業名を含む問い合わせレコードを特定し、連携結果のフィールドだけを更新できる状態です。
STEP 4:登録トリガへPHP実行アクションを設定する
設定内容:問い合わせDBの登録トリガにPHP実行アクションを作成し、次のコードを設定します。フォームからの登録を対象にする場合は、経路条件にフォームを含めます。DBトリガの非同期アクション
設定後の状態:問い合わせが1件登録されると、企業フォルダ作成用PHPが非同期で起動する状態です。
<?php
/**
* SPIRAL WebTools DB登録トリガ用
* 問い合わせDBの企業名から、Google共有ドライブに
* 「SPIRAL / 企業名」フォルダを作成します。
*/
// -----------------------------------------------------------------------------
// SPIRAL WebToolsの設定
// -----------------------------------------------------------------------------
define('SPIRAL_API_URL', 'https://api.spiral-platform.com/v1');
define('SPIRAL_API_KEY', 'YOUR_SPIRAL_API_KEY');
define('SPIRAL_APP_ROLE', 'googleDriveFolderWriter');
define('SPIRAL_APP_ID', 'YOUR_SPIRAL_APP_ID');
define('SPIRAL_DB_ID', 'YOUR_SPIRAL_DB_ID');
define('FIELD_COMPANY_NAME', 'companyName');
define('FIELD_DRIVE_FOLDER_ID', 'driveFolderId');
define('FIELD_DRIVE_FOLDER_URL', 'driveFolderUrl');
define('FIELD_DRIVE_STATUS', 'driveFolderStatus');
define('FIELD_DRIVE_MESSAGE', 'driveFolderMessage');
define('FIELD_DRIVE_PROCESSED_AT', 'driveFolderProcessedAt');
define('STATUS_SUCCESS', '1');
define('STATUS_ERROR', '2');
// -----------------------------------------------------------------------------
// Google OAuthとDrive作成先の設定
// -----------------------------------------------------------------------------
define('GOOGLE_CLIENT_ID', 'YOUR_GOOGLE_CLIENT_ID.apps.googleusercontent.com');
define('GOOGLE_CLIENT_SECRET', 'YOUR_GOOGLE_CLIENT_SECRET');
define('GOOGLE_REFRESH_TOKEN', 'YOUR_GOOGLE_REFRESH_TOKEN');
// 共有ドライブはtrue、個人のマイドライブはfalseにします。
define('GOOGLE_USE_SHARED_DRIVE', true);
define('GOOGLE_SHARED_DRIVE_ID', 'YOUR_GOOGLE_SHARED_DRIVE_ID');
// 共有ドライブ直下なら共有ドライブID、マイドライブなら既存フォルダIDを設定します。
define('GOOGLE_BASE_PARENT_ID', 'YOUR_GOOGLE_BASE_PARENT_ID');
define('GOOGLE_SPIRAL_FOLDER_NAME', 'SPIRAL');
define('GOOGLE_TOKEN_URL', 'https://oauth2.googleapis.com/token');
define('GOOGLE_DRIVE_API_URL', 'https://www.googleapis.com/drive/v3');
define('GOOGLE_FOLDER_MIME_TYPE', 'application/vnd.google-apps.folder');
define('HTTP_CONNECT_TIMEOUT_SECONDS', 7);
define('HTTP_TIMEOUT_SECONDS', 12);
define('SPIRAL_HTTP_CONNECT_TIMEOUT_SECONDS', 3);
define('SPIRAL_HTTP_TIMEOUT_SECONDS', 5);
/**
* HTTP通信を行います。
* 戻り値はHTTPステータスとレスポンス本文です。
*/
function sendRequest(
$method,
$url,
array $headers,
$body = null,
$label = 'HTTP通信',
$connectTimeout = HTTP_CONNECT_TIMEOUT_SECONDS,
$timeout = HTTP_TIMEOUT_SECONDS
)
{
$curl = curl_init();
if ($curl === false) {
throw new RuntimeException($label . 'を開始できませんでした。');
}
curl_setopt($curl, CURLOPT_URL, $url);
curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);
curl_setopt($curl, CURLOPT_CUSTOMREQUEST, $method);
curl_setopt($curl, CURLOPT_HTTPHEADER, $headers);
curl_setopt($curl, CURLOPT_CONNECTTIMEOUT, (int) $connectTimeout);
curl_setopt($curl, CURLOPT_TIMEOUT, (int) $timeout);
curl_setopt($curl, CURLOPT_SSL_VERIFYPEER, true);
curl_setopt($curl, CURLOPT_SSL_VERIFYHOST, 2);
if ($body !== null) {
curl_setopt($curl, CURLOPT_POSTFIELDS, $body);
}
$responseBody = curl_exec($curl);
if ($responseBody === false) {
$message = curl_error($curl);
curl_close($curl);
throw new RuntimeException($label . 'に失敗しました: ' . $message);
}
$status = (int) curl_getinfo($curl, CURLINFO_HTTP_CODE);
curl_close($curl);
return array(
'status' => $status,
'body' => (string) $responseBody,
);
}
/**
* JSON文字列を配列へ変換します。
*/
function readJson($json, $label)
{
$data = json_decode((string) $json, true);
if (!is_array($data)) {
throw new RuntimeException($label . 'を読み取れませんでした。');
}
return $data;
}
/**
* ログやDBへ保存するエラー文を短く整えます。
*/
function shortMessage($message)
{
$message = preg_replace('/\s+/u', ' ', trim((string) $message));
if (!is_string($message)) {
$message = '';
}
if (mb_strlen($message, 'UTF-8') > 1000) {
$message = mb_substr($message, 0, 1000, 'UTF-8') . '...';
}
return $message;
}
/**
* APIのエラー本文から、確認に使うメッセージを取り出します。
*/
function getApiError(array $data, $fallback)
{
if (isset($data['errors']) && is_array($data['errors'])) {
$messages = array();
foreach ($data['errors'] as $error) {
if (!is_array($error)) {
continue;
}
$message = '';
if (!empty($error['location'])) {
$message .= (string) $error['location'] . ': ';
}
if (!empty($error['message'])) {
$message .= (string) $error['message'];
}
if (!empty($error['code'])) {
$message .= ' [' . (string) $error['code'] . ']';
}
if ($message !== '') {
$messages[] = $message;
}
}
if (count($messages) > 0) {
return shortMessage(implode(' / ', $messages));
}
}
if (isset($data['error']['message']) && is_string($data['error']['message'])) {
return shortMessage($data['error']['message']);
}
if (isset($data['error_description']) && is_string($data['error_description'])) {
return shortMessage($data['error_description']);
}
if (isset($data['message']) && is_string($data['message'])) {
return shortMessage($data['message']);
}
return shortMessage($fallback);
}
/**
* 企業名をフォルダ名として使える形に整えます。
*/
function cleanFolderName($name)
{
$name = trim((string) $name);
$name = preg_replace('/[\x00-\x1F\x7F]/u', ' ', $name);
$name = preg_replace('/\s+/u', ' ', (string) $name);
return trim((string) $name);
}
/**
* Googleの短期アクセストークンを取得します。
*/
function getGoogleAccessToken()
{
$form = array(
'client_id' => GOOGLE_CLIENT_ID,
'client_secret' => GOOGLE_CLIENT_SECRET,
'refresh_token' => GOOGLE_REFRESH_TOKEN,
'grant_type' => 'refresh_token',
);
$response = sendRequest(
'POST',
GOOGLE_TOKEN_URL,
array('Content-Type: application/x-www-form-urlencoded'),
http_build_query($form, '', '&', PHP_QUERY_RFC3986),
'Google OAuth通信'
);
$data = readJson($response['body'], 'Google OAuthの応答');
if ($response['status'] !== 200 || empty($data['access_token'])) {
throw new RuntimeException(
'Google OAuth認証に失敗しました(HTTP ' . $response['status'] . '): '
. getApiError($data, 'access_tokenがありません。')
);
}
return (string) $data['access_token'];
}
/**
* 作成先のGoogle Driveからフォルダ一覧を取得します。
* Googleの検索条件式は使用せず、名前と親フォルダはPHP側で確認します。
*/
function getDriveFolders($accessToken)
{
$folders = array();
$nextPageToken = '';
do {
$parameters = array(
'spaces' => 'drive',
'pageSize' => '1000',
'fields' => 'nextPageToken,files(id,name,mimeType,parents,trashed,webViewLink)',
);
if (GOOGLE_USE_SHARED_DRIVE) {
$parameters['corpora'] = 'drive';
$parameters['driveId'] = GOOGLE_SHARED_DRIVE_ID;
$parameters['includeItemsFromAllDrives'] = 'true';
$parameters['supportsAllDrives'] = 'true';
} else {
$parameters['corpora'] = 'user';
}
if ($nextPageToken !== '') {
$parameters['pageToken'] = $nextPageToken;
}
$url = GOOGLE_DRIVE_API_URL . '/files?'
. http_build_query($parameters, '', '&', PHP_QUERY_RFC3986);
$response = sendRequest(
'GET',
$url,
array('Authorization: Bearer ' . $accessToken),
null,
'Google Drive一覧通信'
);
$data = readJson($response['body'], 'Drive一覧の応答');
if ($response['status'] !== 200) {
throw new RuntimeException(
'Drive一覧の取得に失敗しました(HTTP ' . $response['status'] . '): '
. getApiError($data, $response['body'])
);
}
if (isset($data['files']) && is_array($data['files'])) {
foreach ($data['files'] as $file) {
if (!is_array($file)) {
continue;
}
if (!isset($file['mimeType']) || $file['mimeType'] !== GOOGLE_FOLDER_MIME_TYPE) {
continue;
}
$folders[] = $file;
}
}
$nextPageToken = isset($data['nextPageToken'])
? (string) $data['nextPageToken']
: '';
} while ($nextPageToken !== '');
return $folders;
}
/**
* 取得済みの一覧から、親フォルダと名前が一致するフォルダを探します。
*/
function findFolder(array $folders, $parentId, $folderName)
{
foreach ($folders as $folder) {
if (!empty($folder['trashed'])) {
continue;
}
if (!isset($folder['name']) || (string) $folder['name'] !== (string) $folderName) {
continue;
}
if (!isset($folder['parents']) || !is_array($folder['parents'])) {
continue;
}
if (in_array((string) $parentId, $folder['parents'], true)) {
return $folder;
}
}
return null;
}
/**
* Google Driveにフォルダを1つ作成します。
*/
function createFolder($accessToken, $parentId, $folderName)
{
$requestData = array(
'name' => (string) $folderName,
'mimeType' => GOOGLE_FOLDER_MIME_TYPE,
'parents' => array((string) $parentId),
);
$requestBody = json_encode(
$requestData,
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES
);
if ($requestBody === false) {
throw new RuntimeException('Drive作成データをJSONへ変換できませんでした。');
}
$parameters = array(
'fields' => 'id,name,webViewLink',
);
if (GOOGLE_USE_SHARED_DRIVE) {
$parameters['supportsAllDrives'] = 'true';
}
$url = GOOGLE_DRIVE_API_URL . '/files?'
. http_build_query($parameters, '', '&', PHP_QUERY_RFC3986);
$response = sendRequest(
'POST',
$url,
array(
'Authorization: Bearer ' . $accessToken,
'Content-Type: application/json; charset=UTF-8',
),
$requestBody,
'Google Driveフォルダ作成通信'
);
$data = readJson($response['body'], 'Drive作成の応答');
if ($response['status'] !== 200 || empty($data['id'])) {
throw new RuntimeException(
'Driveフォルダ作成に失敗しました(HTTP ' . $response['status'] . '): '
. getApiError($data, $response['body'])
);
}
return $data;
}
/**
* 問い合わせレコードへDrive連携結果を書き戻します。
*/
function updateSpiralRecord($recordId, array $values)
{
$requestData = $values;
$requestBody = json_encode(
$requestData,
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES
);
if ($requestBody === false) {
throw new RuntimeException('SPIRAL更新データをJSONへ変換できませんでした。');
}
$url = SPIRAL_API_URL
. '/apps/' . rawurlencode(SPIRAL_APP_ID)
. '/dbs/' . rawurlencode(SPIRAL_DB_ID)
. '/records/' . rawurlencode((string) $recordId);
$response = sendRequest(
'PATCH',
$url,
array(
'Authorization: Bearer ' . SPIRAL_API_KEY,
'X-Spiral-Api-Version: 1.1',
'X-Spiral-App-Authority: use',
'X-Spiral-App-Role: ' . SPIRAL_APP_ROLE,
'Content-Type: application/json; charset=UTF-8',
),
$requestBody,
'SPIRALレコード更新通信',
SPIRAL_HTTP_CONNECT_TIMEOUT_SECONDS,
SPIRAL_HTTP_TIMEOUT_SECONDS
);
$data = readJson($response['body'], 'SPIRAL更新の応答');
if ($response['status'] !== 200) {
throw new RuntimeException(
'SPIRALレコード更新に失敗しました(HTTP ' . $response['status'] . '): '
. getApiError($data, $response['body'])
);
}
}
// -----------------------------------------------------------------------------
// ここから登録トリガの処理です。
// -----------------------------------------------------------------------------
$triggerRecord = $SPIRAL->getRecord();
if (!isset($triggerRecord['item']) || !is_array($triggerRecord['item'])) {
throw new RuntimeException('トリガ対象レコードを取得できませんでした。');
}
$item = $triggerRecord['item'];
$recordId = isset($item['_id']) ? (string) $item['_id'] : '';
if ($recordId === '') {
throw new RuntimeException('トリガ対象レコードIDを取得できませんでした。');
}
// フォルダIDが保存済みなら、再作成せず終了します。
if (!empty($item[FIELD_DRIVE_FOLDER_ID])) {
return;
}
try {
$companyName = isset($item[FIELD_COMPANY_NAME])
? cleanFolderName($item[FIELD_COMPANY_NAME])
: '';
if ($companyName === '') {
throw new RuntimeException('企業名が空のため、フォルダを作成できません。');
}
$accessToken = getGoogleAccessToken();
$folders = getDriveFolders($accessToken);
$spiralFolder = findFolder(
$folders,
GOOGLE_BASE_PARENT_ID,
GOOGLE_SPIRAL_FOLDER_NAME
);
if ($spiralFolder === null) {
$spiralFolder = createFolder(
$accessToken,
GOOGLE_BASE_PARENT_ID,
GOOGLE_SPIRAL_FOLDER_NAME
);
}
$spiralFolderId = (string) $spiralFolder['id'];
$companyFolder = findFolder($folders, $spiralFolderId, $companyName);
$created = false;
if ($companyFolder === null) {
$companyFolder = createFolder($accessToken, $spiralFolderId, $companyName);
$created = true;
}
$companyFolderId = (string) $companyFolder['id'];
$companyFolderUrl = !empty($companyFolder['webViewLink'])
? (string) $companyFolder['webViewLink']
: 'https://drive.google.com/drive/folders/' . rawurlencode($companyFolderId);
$resultText = $created ? '新規作成' : '既存フォルダを再利用';
updateSpiralRecord(
$recordId,
array(
FIELD_DRIVE_FOLDER_ID => $companyFolderId,
FIELD_DRIVE_FOLDER_URL => $companyFolderUrl,
FIELD_DRIVE_STATUS => STATUS_SUCCESS,
FIELD_DRIVE_MESSAGE => $resultText . ': SPIRAL / ' . $companyName,
FIELD_DRIVE_PROCESSED_AT => gmdate('Y-m-d\TH:i:s\Z'),
)
);
} catch (Throwable $error) {
$errorMessage = shortMessage($error->getMessage());
try {
updateSpiralRecord(
$recordId,
array(
FIELD_DRIVE_STATUS => STATUS_ERROR,
FIELD_DRIVE_MESSAGE => $errorMessage,
FIELD_DRIVE_PROCESSED_AT => gmdate('Y-m-d\TH:i:s\Z'),
)
);
} catch (Throwable $updateError) {
throw new RuntimeException(
$errorMessage . ' / エラー結果の保存にも失敗しました: '
. shortMessage($updateError->getMessage()),
0,
$error
);
}
throw $error;
}
STEP 5:環境ごとの定数を設定する
| 定数 | 設定する値 |
|---|---|
SPIRAL_API_KEY | 専用APIエージェントのAPIキー |
SPIRAL_APP_ROLE | 最小権限のアプリロール識別名 |
SPIRAL_APP_ID | 問い合わせDBを含むアプリID |
SPIRAL_DB_ID | 問い合わせDBのDBID |
GOOGLE_CLIENT_ID | Google OAuthクライアントID |
GOOGLE_CLIENT_SECRET | Google OAuthクライアントシークレット |
GOOGLE_REFRESH_TOKEN | OAuth担当ユーザーの更新トークン |
GOOGLE_USE_SHARED_DRIVE | 共有ドライブはtrue、マイドライブはfalse |
GOOGLE_SHARED_DRIVE_ID | 対象共有ドライブのID。マイドライブでは空文字 |
GOOGLE_BASE_PARENT_ID | 共有ドライブ直下は共有ドライブID、マイドライブは既存テストフォルダID |
設定内容:個人マイドライブで確認するときは
GOOGLE_USE_SHARED_DRIVEをfalse、
GOOGLE_SHARED_DRIVE_IDを空文字、
GOOGLE_BASE_PARENT_IDを既存テストフォルダIDにします。Workspace共有ドライブではtrueに戻し、共有ドライブIDと作成先IDを設定します。秘密値はSPIRAL上の対応する定数へ直接設定します。
設定後の状態:各環境の問い合わせDBとGoogle Drive作成先だけを操作する状態です。
STEP 6:手動実行してから有効化する
設定内容:テスト用問い合わせレコードのIDを使ってPHP実行アクションを手動実行し、Driveの階層と問い合わせDBの結果を確認します。同じレコードの再実行、別レコードの同一企業名、Google認証エラーもテストします。
設定後の状態:正常系とエラー系の確認が完了し、PHP実行アクションが有効な状態です。
STEP 7:月次のOAuth・Drive作成先の死活監視を設定する
設定内容:アプリ管理の「アクション > PHP実行」で月次監視用アクションを作成し、次のコードを設定します。コード1と同じGoogle OAuth情報、Driveモード、共有ドライブID、作成先IDへ置換し、手動実行が「実行終了」になることを確認します。その後、スケジュールトリガを「毎月1日 09:00」などに設定して監視用PHPを追加し、エラー通知メールを有効にします。スケジュールトリガ、スケジュールトリガのPHP実行アクション
設定後の状態:問い合わせが長期間ない場合も毎月更新トークンが使用され、設定したDrive作成先の取得まで自動確認される状態です。
<?php
/**
* SPIRAL WebTools スケジュールトリガ用
* Google OAuth更新トークンとGoogle Drive作成先の月次死活監視です。
*
* 設置先:
* スケジュールトリガ(毎月) > PHP実行アクション
*
* 成功時:
* 例外を発生させず終了し、アクション履歴に「実行終了」が残ります。
*
* 失敗時:
* 例外を発生させ、アクション履歴とエラー通知メールに理由を残します。
*/
// =============================================================================
// 1. Google OAuth / Drive設定値
// code1.phpと必ず同じ値を設定してください。
// =============================================================================
define('GOOGLE_CLIENT_ID', 'YOUR_GOOGLE_CLIENT_ID.apps.googleusercontent.com');
define('GOOGLE_CLIENT_SECRET', 'YOUR_GOOGLE_CLIENT_SECRET');
define('GOOGLE_REFRESH_TOKEN', 'YOUR_GOOGLE_REFRESH_TOKEN');
// 共有ドライブはtrue、個人のマイドライブはfalseにします。
define('GOOGLE_USE_SHARED_DRIVE', true);
define('GOOGLE_SHARED_DRIVE_ID', 'YOUR_GOOGLE_SHARED_DRIVE_ID');
define('GOOGLE_BASE_PARENT_ID', 'YOUR_GOOGLE_BASE_PARENT_ID');
define('GOOGLE_TOKEN_URL', 'https://oauth2.googleapis.com/token');
define('GOOGLE_DRIVE_API_URL', 'https://www.googleapis.com/drive/v3');
define('GOOGLE_FOLDER_MIME_TYPE', 'application/vnd.google-apps.folder');
// PHP実行アクション全体は30秒制限のため、各外部通信を短めに打ち切ります。
define('HTTP_CONNECT_TIMEOUT_SECONDS', 7);
define('HTTP_TIMEOUT_SECONDS', 12);
// =============================================================================
// 2. 共通関数
// =============================================================================
/**
* @return array{status:int, body:string}
*/
function requestHttp($method, $url, array $headers, $body = null, $label = 'HTTP通信')
{
$curl = curl_init();
if ($curl === false) {
throw new RuntimeException($label . 'を開始できませんでした。');
}
$options = array(
CURLOPT_URL => $url,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_CONNECTTIMEOUT => HTTP_CONNECT_TIMEOUT_SECONDS,
CURLOPT_TIMEOUT => HTTP_TIMEOUT_SECONDS,
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_SSL_VERIFYHOST => 2,
);
if ($body !== null) {
$options[CURLOPT_POSTFIELDS] = $body;
}
curl_setopt_array($curl, $options);
$responseBody = curl_exec($curl);
$curlError = curl_error($curl);
$statusCode = (int) curl_getinfo($curl, CURLINFO_HTTP_CODE);
curl_close($curl);
if ($responseBody === false) {
throw new RuntimeException($label . 'に失敗しました: ' . safeMessage($curlError));
}
return array(
'status' => $statusCode,
'body' => (string) $responseBody,
);
}
function decodeJson($json, $context)
{
$decoded = json_decode($json, true);
if (!is_array($decoded)) {
throw new RuntimeException($context . 'のJSON解析に失敗しました。');
}
return $decoded;
}
function safeMessage($message)
{
$message = (string) $message;
$message = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/u', '', $message);
if ($message === null) {
$message = '';
}
if (mb_strlen($message, 'UTF-8') > 1000) {
$message = mb_substr($message, 0, 1000, 'UTF-8') . '...';
}
return $message;
}
function apiErrorDetail(array $decoded, $fallback)
{
if (isset($decoded['error']['message']) && is_string($decoded['error']['message'])) {
return safeMessage($decoded['error']['message']);
}
if (isset($decoded['error_description']) && is_string($decoded['error_description'])) {
return safeMessage($decoded['error_description']);
}
if (isset($decoded['message']) && is_string($decoded['message'])) {
return safeMessage($decoded['message']);
}
return safeMessage($fallback);
}
// =============================================================================
// 3. Google OAuth / Drive関数
// =============================================================================
function getGoogleAccessToken()
{
$parameters = array(
'client_id' => GOOGLE_CLIENT_ID,
'client_secret' => GOOGLE_CLIENT_SECRET,
'refresh_token' => GOOGLE_REFRESH_TOKEN,
'grant_type' => 'refresh_token',
);
$requestBody = http_build_query(
$parameters,
'',
'&',
PHP_QUERY_RFC3986
);
$response = requestHttp(
'POST',
GOOGLE_TOKEN_URL,
array('Content-Type: application/x-www-form-urlencoded'),
$requestBody,
'Google OAuth通信'
);
$decoded = decodeJson($response['body'], 'Google OAuthレスポンス');
if ($response['status'] !== 200 || empty($decoded['access_token'])) {
throw new RuntimeException(
'Google OAuth更新トークンの確認に失敗しました(HTTP '
. $response['status'] . '): '
. apiErrorDetail($decoded, 'access_tokenがありません。')
);
}
return (string) $decoded['access_token'];
}
/**
* OAuthユーザーが設定したGoogle Drive作成先へアクセスできることを確認します。
*/
function verifyDriveAccess($accessToken)
{
if (GOOGLE_USE_SHARED_DRIVE) {
$url = GOOGLE_DRIVE_API_URL
. '/drives/' . rawurlencode(GOOGLE_SHARED_DRIVE_ID)
. '?fields=id%2Cname';
$response = requestHttp(
'GET',
$url,
array(
'Authorization: Bearer ' . $accessToken,
'Accept: application/json',
),
null,
'Google共有ドライブ確認通信'
);
$decoded = decodeJson($response['body'], 'Google共有ドライブ確認レスポンス');
if (
$response['status'] !== 200
|| empty($decoded['id'])
|| (string) $decoded['id'] !== GOOGLE_SHARED_DRIVE_ID
) {
throw new RuntimeException(
'対象共有ドライブの権限確認に失敗しました(HTTP '
. $response['status'] . '): '
. apiErrorDetail($decoded, '共有ドライブを取得できません。')
);
}
}
$parameters = array(
'fields' => 'id,name,mimeType,trashed',
);
$locationName = 'マイドライブ作成先';
if (GOOGLE_USE_SHARED_DRIVE) {
$parameters['supportsAllDrives'] = 'true';
$locationName = '共有ドライブ作成先';
}
$url = GOOGLE_DRIVE_API_URL
. '/files/' . rawurlencode(GOOGLE_BASE_PARENT_ID)
. '?' . http_build_query($parameters, '', '&', PHP_QUERY_RFC3986);
$response = requestHttp(
'GET',
$url,
array(
'Authorization: Bearer ' . $accessToken,
'Accept: application/json',
),
null,
'Google Drive作成先確認通信'
);
$decoded = decodeJson($response['body'], 'Google Drive作成先確認レスポンス');
if (
$response['status'] !== 200
|| empty($decoded['id'])
|| (string) $decoded['id'] !== GOOGLE_BASE_PARENT_ID
|| !isset($decoded['mimeType'])
|| (string) $decoded['mimeType'] !== GOOGLE_FOLDER_MIME_TYPE
|| !empty($decoded['trashed'])
) {
throw new RuntimeException(
$locationName . 'の確認に失敗しました(HTTP '
. $response['status'] . '): '
. apiErrorDetail($decoded, '作成先フォルダを取得できません。')
);
}
}
// =============================================================================
// 4. メイン処理
// =============================================================================
try {
$accessToken = getGoogleAccessToken();
verifyDriveAccess($accessToken);
} catch (Throwable $error) {
throw new RuntimeException(
'Google Drive月次死活監視に失敗しました: ' . safeMessage($error->getMessage()),
0,
$error
);
}
エラーハンドリング(失敗時の動き)
| 失敗箇所 | 問い合わせDB | 確認方法 |
|---|---|---|
| 企業名が空 | 状態をエラーにし、企業名不足を記録 | フォームの必須設定と登録レコードを確認 |
| Google OAuth認証 | HTTP状態とエラー概要を記録 | 更新トークン、OAuth公開状態、管理者ポリシーを確認 |
| Driveの検索・作成 | Drive APIのエラー概要を記録 | スコープ、作成先ID、連携ユーザーの権限を確認 |
| SPIRALへの書き戻し | 結果を保存できない場合がある | PHP実行ログでAPIキー、アプリロール、フィールド権限を確認 |
| 30秒タイムアウト | 途中結果が残らない場合がある | PHP実行ログとDriveの実体を確認し、手動再実行 |
| 月次死活監視 | 問い合わせDBは更新しない | アクション履歴と通知メールを確認し、再認証またはDrive作成先の権限を修復 |
エラー内容を問い合わせDBへ保存した後も、PHP実行ログとエラー通知メールに残すため、サンプルは例外を再送出します。Driveフォルダ作成後にSPIRALへの書き戻しだけが失敗した場合、手動再実行すると既存の同名フォルダを再利用して書き戻しをやり直します。
実行結果
企業名が「株式会社サンプル」の問い合わせを登録すると、Google Driveに次の階層が作成されます。
設定したGoogle Driveの作成先 └─ SPIRAL └─ 株式会社サンプル
問い合わせレコードには、企業フォルダのID、URL、成功状態、処理メッセージ、処理日時が保存されます。同じ企業名が別の問い合わせから登録された場合は、SPIRALフォルダ直下の既存企業フォルダを再利用します。
まとめ
問い合わせDBの登録トリガとPHP実行アクションを使うことで、問い合わせ受付後の企業フォルダ作成を自動化できます。
「SPIRAL / 企業名」という固定された階層に揃え、フォルダIDとURLを問い合わせDBへ戻すことで、Google Driveと問い合わせ情報の対応関係も追跡しやすくなります。
個人マイドライブで事前確認した後、本番運用では共有ドライブモードへ切り替え、Workspace全員を含むGoogleグループによる共有、OAuth担当ユーザーへの投稿者権限、InternalのOAuthアプリ、月次のOAuth・Drive作成先監視、最小権限のSPIRALアプリロール、同時実行時の重複対策を合わせて設計してください。