開発情報・ナレッジ

投稿者: ShiningStar株式会社 2026年9月25日 (金)

SPIRALのDB登録時に、お問い合わせフォームへの企業名のGoogle Driveフォルダを自動作成する流れ

SPIRAL WebToolsの問い合わせフォームから企業情報を受け付けた後、Google Driveに企業ごとの作業フォルダを手作業で用意していませんか。
本記事では、問い合わせDBへの登録をきっかけにPHPを非同期実行し、Google Driveへ「SPIRAL / 企業名」の2階層を自動作成する方法をご紹介します。Google Workspaceの共有ドライブと、個人Googleアカウントのマイドライブを定数で切り替えられます。
作成したフォルダIDとURLは問い合わせDBへ書き戻すため、問い合わせレコードから企業フォルダを追跡できます。

注意点

・ 本サンプルはSPIRAL WebToolsのDB登録トリガに設定するPHP実行アクションを対象としています。
・ 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をご確認ください。

ユーザーの更新トークンを一切使わない構成では、Google Cloudのサービスアカウントを使用します。ただし、SPIRAL WebTools内ではサービスアカウント用JWTの署名機能が制限されるため、Cloud Runなどの認証済み中継APIへDrive処理を分離する必要があります。ドメイン全体の権限委任を使う場合も、組織そのものとして動作するのではなく、指定したユーザーを偽装してその権限で動作します。サービスアカウントによるサーバー間OAuth

実装の概要

問い合わせフォームからDB登録トリガのPHP、Google Drive APIを経由し、SPIRALフォルダの下に企業名フォルダを作って結果を問い合わせDBへ戻す構成図
問い合わせ登録から企業フォルダ作成、問い合わせDBへの結果書き戻しまでの流れ
1. 問い合わせフォームから企業名を含むレコードを登録します。
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クライアントIDGoogle OAuthのクライアントを識別する専用のGoogle Cloudプロジェクトで管理
Googleクライアントシークレット更新トークン交換時のクライアント認証記事・Git・実行ログへ記録しない
Google更新トークンユーザー不在時にアクセストークンを取得するOAuth担当ユーザーで発行し、失効時の再認証手順を用意
Driveモード共有ドライブとマイドライブを切り替える共有ドライブはtrue、マイドライブはfalse
共有ドライブID共有ドライブモードの検索・作成対象を固定するマイドライブモードでは使用しない
Google Drive作成先IDSPIRALフォルダを作る基準位置共有ドライブ直下は共有ドライブ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を呼べる状態です。

Google Auth Platformの準備:Google Cloud ConsoleでGoogle Drive APIを有効にします。Google Auth Platformの「Branding」でアプリ名・サポートメール・連絡先を設定します。Audienceは、Workspace共有ドライブの本番運用ではInternal、個人マイドライブの確認ではExternal / Testingにして対象アカウントをテストユーザーへ追加します。「Data Access」の「Add or remove scopes」から
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 flowServer-side認可コードをトークンへ交換する
Access typeOfflineユーザー不在時に使う更新トークンを取得する
Force promptConsent Screen同意画面を明示的に表示する
OAuth Client ID作成したクライアントID対象CloudプロジェクトのOAuthアプリを使用する
OAuth Client secret作成したクライアントシークレット認可コード交換時にOAuthクライアントを認証する
1. OAuth 2.0 Playgroundを開き、右上の歯車から上表の設定を行います。
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を外します。再発行時は一時的に戻します。
Playground標準の認証情報で発行した更新トークンは24時間後に取り消されるため、必ず「Use your own OAuth credentials」を有効にしてください。Refresh tokenが表示されない場合は、Access typeがOffline、Force promptがConsent Screenになっていることと、OAuth担当ユーザーで承認していることを確認します。それでも取得できない場合は、Googleアカウントの「サードパーティとの接続」から対象アプリのアクセスを削除し、同意操作をやり直します。Googleアカウントへのアクセスを確認・削除する
Testingを外す条件:共有ドライブの本番運用では、Externalを公開するのではなくInternalを使用します。Cloudプロジェクトが対象Workspace組織配下にあり、承認ユーザーが同じ組織内だけで、AudienceがInternalになっていることが条件です。個人Googleアカウントのマイドライブ確認ではInternalを選べないため、External / Testingとテストユーザーを使用します。Driveスコープを承認したTestingの更新トークンは7日で期限切れになるため、一時的な動作確認用です。InternalアプリでOAuth検証が不要になる条件、OAuthアプリの公開状態
投稿者以上を付ける効果:Drive APIはOAuth担当ユーザー本人の権限で動作します。共有ドライブが組織所有でも、そのユーザーに権限がなければ検索・作成APIは失敗します。投稿者はファイルやフォルダを追加するための最小権限で、今回のフォルダ作成に必要です。移動・削除まで自動化する場合はコンテンツ管理者、メンバー管理まで行う場合は管理者が必要ですが、本サンプルには不要です。共有ドライブへファイルやフォルダを追加する権限
既存フォルダを検索して利用するため、本サンプルでは
https://www.googleapis.com/auth/drive
を使用します。このスコープは制限付きです。共有ドライブモードでは
corpora=drive
と
driveId
で対象を固定し、マイドライブモードでは共有ドライブ専用パラメータを送信せず、設定した親フォルダIDの配下だけを作成対象にします。Drive APIの認証スコープ
STEP 3:SPIRAL APIの最小権限を用意する

設定内容:専用APIエージェントを用意し、問い合わせDBのレコード閲覧とDrive連携結果フィールドの変更だけを許可するアプリロールを設定します。SPIRAL WebTools APIリファレンス
設定後の状態:PHPが、企業名を含む問い合わせレコードを特定し、連携結果のフィールドだけを更新できる状態です。

レコードIDはPATCH先URLで指定します。JSON本文は更新するフィールドをトップレベルに置き、
item
で囲まず、システム項目
_id
も追加しません。レコード変更APIの公式サンプル
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
    );
}

            
月次監視は更新トークンを新しい更新トークンへ交換する処理ではありません。既存の更新トークンからアクセストークンを取得して6か月未使用を避け、そのアクセストークンで共有ドライブまたはマイドライブの作成先を取得できるか確認します。失敗時はPHP例外によりアクション履歴とエラー通知メールへ理由を残します。修復後は手動実行を成功させ、停止中になったPHP実行アクションとスケジュールトリガを再度有効にしてください。

エラーハンドリング(失敗時の動き)

失敗箇所問い合わせ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アプリロール、同時実行時の重複対策を合わせて設計してください。

解決しない場合はこちら コンテンツに関しての
要望はこちら