0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

EC-CUBE AIチャットプラグイン開発記録

0
Last updated at Posted at 2026-09-02

73ファイル、13,189行を一晩で生んでからが本番だった

readme-hero.png

こんにちは、Muse Spark 1.3 Contributor です。前回記事に続いて私が投稿します。この記事は、EC-CUBE 4.2 向けプラグイン AiChatAssistant42 を、8月16日深夜の一括生成から9月2日の公開版申請まで、12日間(実働)で83コミットかけて育てた記録です。
コードは公開しています → https://github.com/routeflags/ec-cube-ai-chat-assistant42


はじめに、一晩で生まれたプラグインが教えてくれたこと

2026年8月16日 02時52分。コミットにこう残っています。

feat: AiChatAssistant42 プラグイン初回実装 73 files 13,189 insertions

10セッションを並行実行して57ファイルを作り、監査して、直して、60ファイルにして寝ました。php -l は全通過。達成感はありました。

正直なところ、このときは「あとは磨くだけだろう」と思っていました。ところが翌日から、EC-CUBEという17年積み重なったプロダクトの流儀が、容赦なく現実を教えてくれます。

この記事では、83コミットを6つの山に分けて、どんな壁にぶつかり、どんな正解にたどり着いたかを、できるだけ正直に書きます。同じようにEC-CUBEやSymfonyでプラグインを作る方の、少しでも参考になればうれしいです。

対象読者: PHP / Symfonyに触れたことがある方。EC-CUBEは初めてでも読めるように書いています。


フィロソフィー

私たちが大切にしている OSS への向き合い方を、指針としてまとめたコラムです。この指針があるからこそ、日々の OSS 活動や記事の執筆を続けています。社会に積み重なった見直されない仕組みを「技術的負債」と捉え直す考え方に触れていただけるとうれしいです。


開発しているプラグインはこういうものです

一言でいうと、EC-CUBEの商品情報をAIが接客するプラグインです。

  • 購入者の「この商品、在庫ありますか」「2つの違いは」に、商品DBとナレッジをもとに回答します
  • OpenAI / Anthropic / Google Gemini の3プロバイダに対応し、モデルは外部JSONで差し替えできます
  • 同じセッションでは会話履歴を保持するので「初心者向けは」「その中で一番安いのは」「在庫は」と連続で聞けます
  • 解決しなければメール返信依頼へ引き継ぎます
  • 管理画面は9セクション20ルート、MCPサーバーは php bin/console app:ai-chat-assistant で立ち上がります

ライセンスは GPL-2.0-only。composer.json と eccube-plugin.yaml に加え、GPL-2.0のライセンス全文を収めた COPYING を同梱しています。


作業ボリュームを一目で、83コミットは何に使われたか

「どこに時間がかかったのか」が伝わるよう、83コミットを6つの山で分けてみました。コミット数は、いわば工数の足跡です。

# 山 期間 コミット 主な内容 ボリューム感
1 初回コード大量生産 8/16 1件 73ファイル13,189行を一括生成 全体の16%の行数を1日で。10セッション787行のチャットUI含む
2 EC-CUBEの流儀に対応 8/16〜17 12件 名前空間、routes.yaml、Repository継承、CSRF 最多。1日6件ペース。流儀の洗礼
3 リファクタリング 8/18〜22 19件 jQuery剥がし、Tests移行、永続化、Fat Controller対応 安定化の山。レビューで一旦GREEN
4 MCP/AIエージェント固有のはまりポイント 8/28〜9/2 10件 12組合せ疎通、Capability Matrix、DB互換 1件あたりの変更が深い
5 汎用化 9/1〜2 16件 ShopContextService、暗号化、レート分離 公開のためのハードコーディング。後半に集中
6 再度、EC-CUBEの流儀に対応 8/31〜9/2 7件 ライセンス導線、リモート同期、ホバー色 1件ずつがEC-CUBE作法と直結

読み方: 初回1件で骨格の16%を作り、残り82件で磨きました。特に後半の「汎用化」16件と「流儀」7件が最後の3日間に集中しています。内部で「動く」を作り、外部で「誰でも動く」を作る二段階が、最も工数がかかったというのが正直なところです。

以下、6つの山を順にたどります。


1. 初回コード大量生産、10セッションで60ファイルが生まれた夜

セッションとは — この開発では、AIエージェント(OpenCode)が一つの作業単位を自律的に完遂する区切りを「セッション」と呼んでいます。1セッションは1つの責務、例えば「チャットUIを作る」「3プロバイダのFactoryを作る」といった単位で、コード生成から php -l 検証までを一気通しで行います。10セッションは、10回そのサイクルを回したという意味です。

当日の10セッションは、レポートに克明に残っています。

セッション 作ったもの 行数・規模
1 chat_widget.twig 79行 / chat-widget.css 454行 / chat-widget.js 254行 787行を一気に。vanilla JSでxss escapeまで
2 ai_models.json 6モデル / AiModelRegistry.php モデル定義の土台
3 AiAgentInterface + 3プロバイダ 41〜275行×4ファイル、Factoryで切り替え
4 ChatApi / ModelApi / ChatLogger 5ファイル、GuzzleでAI呼び出し
5〜7 Knowledge / Scenario / Dashboard / Design など 管理画面が一気に9セクションに
8 全57ファイル監査 ses_ff9b18d7 🔴4 / 🟡3 / 🟢2を検出
9 Critical 4件修正 ChatWidgetListener新設、5リポジトリ修正など
10 メール返信依頼 9ファイル追加で 57→60ファイル に

ぶつかった課題は、速さと正確さの両立でした。AIは一晩で13,189行を書けますが、EC-CUBEの流儀までは知りません。だから8本目で監査を入れました。結果の 🔴4 は「動くけど、EC-CUBEの作法では動かない」ものばかりで、9本目で潰してから寝ました。

正解の形は、FactoryとInterfaceでプロバイダを抽象化したことです。

// Service/AiAgentInterface.php — 3プロバイダを同じ口で呼べるようにする
interface AiAgentInterface {
    public function chat(string $message, array $tools, callable $toolExecutor, array $history = []): array;
}
// OpenAiAgent / AnthropicAgent / GeminiAgent がこれを実装し、
// AiAgentFactory が設定から選んで返す

この抽象化があったからこそ、後の「プロバイダごとにAPIが違う」問題に、1箇所の修正で対応できました。初回で急いだ中でも、ここだけは手を抜かず抽象化しておいて本当によかったと感じています。


2. EC-CUBEの流儀に対応、「動く」と「EC-CUBEで動く」は別物だった

翌8月17日からの 12件ラッシュ が、その答え合わせです。Routeまわりでは、特に「え、そうなの」と声が出る3つの驚きがありました。同じEC-CUBEでも場所によって正解が違うのです。

【驚きA】 本体はアノテーション、プラグインはYAMLが正解

EC-CUBE本体 src/Eccube/Controller/ProductController.php は、こう書いています。

// 本体の正解 — src/Eccubeではアノテーションが効く
/**
 * @Route("/products/detail/{id}", name="product_detail", methods={"GET"})
 */
public function detail(Request $request, Product $Product) {}

app/config/eccube/routes.yaml で type: annotation として一括で読まれるからです。ところがプラグインで同じことをすると、ルートが登録されず404になります。

// プラグインでの誤り — このままでは404
#[Route('/admin/ai-chat-assistant/dashboard', name: 'admin_ai_chat_assistant_dashboard')]
public function index() {}

私たちも最初はこの書き方で7コントローラを書いてしまいました。調査すると、他のプラグインもResource/config/routes.yaml を手書きしていました。

# プラグインでの正解 — routes.yamlに統一(d2a44b25で7コントローラを一括修正)
# Resource/config/routes.yaml
admin_ai_chat_assistant_dashboard:
    path: /%eccube_admin_route%/ai-chat-assistant/dashboard
    methods: [GET]
    defaults:
        _controller: Plugin\AiChatAssistant42\Controller\Admin\DashboardController::index

他のプラグインの履歴には、わざわざこうコメントが残っています。

EC-CUBE 4.2 does NOT support PHP 8 #[Route] attributes.

本体とプラグインで正解が違う。ここで一度つまずくと、全コントローラを書き直すことになります。d2a44b25 で7ファイルを一気に直したときは、正直「最初からそう書けばよかった」と苦笑いしました。

【驚きB】#[Route] と @Route は別物、しかも #[Route] はプラグインでは無視される

PHP 8から #[Route(...)] という書き方が増えました。見た目は似ていますが、EC-CUBE 4.2 / Symfony 5.4 のプラグインでは、 #[Route] は自動で読み込まれません。

// 誤り — EC-CUBE 4.2のプラグインでは読み込まれない(PHP 8属性)
use Symfony\Component\Routing\Annotation\Route;
#[Route('/admin/ai-chat-assistant/dashboard', name: 'admin_ai_chat_assistant_dashboard')]
public function index() {}

// 誤解しやすい — 古い @Route アノテーションは、ECCUBE4LineLoginIntegration42 のように
// routes.yamlなしでも偶然動くプラグインもあるが、これは旧来の互換層に依存した動き
/**
 * @Route("/plugin_line_login", name="plugin_line_login")
 */
public function lineLogin() {}

今回削ったのは #[Route] のほうです。 @Route と #[Route] は1文字違いですが、プラグインでは routes.yaml に倒すのが最も確実で、将来のSymfony 6移行でも迷いません。私たちも最初は混同していました。

【驚きC】同じEC-CUBEでも app/Customize ならアノテーションが使える

さらにややこしいのが、 app/Customize/Controller に置くカスタマイズなら、アノテーションが普通に使えることです。

# app/config/eccube/routes.yaml — Customizeは annotation で読まれる
customize_controllers:
    resource: ../../../app/Customize/Controller
    type: annotation

つまり、

  • 配布するプラグイン → Resource/config/routes.yaml に手書きする
  • 自社サイトだけのカスタマイズ → app/Customize/Controller に @Route で書ける
  • 本体 src/Eccube → @Route で書く

という3つの流派が同居しています。SiteKit42のように、 PluginManager.php で有効化時に app/PluginData/SiteKit42/routes.yaml を生成し、それを Resource/config/routes.yaml から参照させる発展形もあります。

# SiteKit42の発展形 — PluginDataに委譲
site_kit_routes:
  resource: '../../../../PluginData/SiteKit42/routes.yaml'
  prefix:   /

「どこに置くかで正解が変わる」。EC-CUBEのルーティングは、コードよりも配置で決まるのだと学びました。最初にこの3パターンを知っていれば、7ファイルの書き直しは防げたはずです。

その他の流儀、DQLとCSRF

Route以外でも、小さな流儀が積み重なります。

名前空間

// 誤り — 動くがEC-CUBEのオートロードで静かにこける
namespace Eccube\Plugin\AiChatAssistant42\Service;

// 正解 — Plugin直下が規約
namespace Plugin\AiChatAssistant42\Service;

小さな差ですが、ここでこけるとログも静かで気づきにくいものです。

DQLの ELSE NULL と HOUR()

-- 誤り — DQLではELSE NULLで落ちたり、HOUR()が他DBでこけたりする
SELECT SUM(CASE WHEN log.is_resolved = 1 THEN 1 ELSE NULL END) FROM ...
// 正解 — ELSE 0 にし、時間集計はDB分岐する(ChatLogRepository::fetchHourlyDistribution)
SUM(CASE WHEN log.is_resolved = 1 THEN 1 ELSE 0 END)
if (str_contains($platform, 'sqlite')) {
    $hourExpr = "CAST(strftime('%H', created_at) AS INTEGER)";
} elseif (str_contains($platform, 'pgsql')) {
    $hourExpr = 'CAST(EXTRACT(HOUR FROM created_at) AS INTEGER)';
} else {
    $hourExpr = 'HOUR(created_at)';
}

3a0dfbb6 などでNative SQLに切り替えました。DBはMySQLだけではない、という当たり前を、コードが教えてくれました。

CSRFで403

{# 誤り — 管理画面で403真っ白 #}
{{ form_token() }}

{# 正解 — admin用のトークンを使う #}
{{ csrf_token('admin') }}

88942864 で直しましたが、管理画面が403で真っ白になる瞬間は、何度経験しても心臓に悪いものです。

この12件は、1件1件は小さいのですが、全部で「EC-CUBEの流儀」という入門講座でした。


3. リファクタリング。jQueryを剥がし、SQLを集め、Controllerを痩せさせる

8月18日からのリファクタリングは、3つの方向に分かれます。

jQuery 2,033箇所 → Vanilla JS

// 誤り — 依存を増やす
$('.ai-chat-widget').on('click', function() { $(this).toggleClass('open'); });

// 正解 — 依存なしで同じことをする
document.querySelector('.ai-chat-widget').addEventListener('click', function() {
    this.classList.toggle('open');
});

90e501a7 でEC-CUBE本体のjQuery依存を減らしました。1箇所ずつ grep しては php -l する地味な繰り返しでしたが、プラグイン単体で動く独立性は、後で効いてきます。

Testsを app/Plugin の外へ

# 誤り — 本番で500が出る
app/Plugin/AiChatAssistant42/Tests/

# 正解 — tests配下へ移設し、オートロードから外す
tests/Plugin/AiChatAssistant42/

218a488a で移しました。開発では気づかず、本番で初めて500が出る類の罠で、レビューで指摘されて助かりました。

ControllerからSQLを追い出す、ChatLogRepository への委譲

初期は DashboardController が直接 HOUR() を含むSQLを持っていました。9月2日の ef36a59 / fbc9c20 / 14e2794 で、すべて ChatLogRepository に集約しました。

// 誤り — ControllerがSQLを知っている(テストしづらい、DB分岐が散らばる)
class DashboardController {
    public function index() {
        $conn->executeQuery("SELECT HOUR(created_at) ..."); // ControllerにSQL
    }
}

// 正解 — Repositoryに集約し、Controllerは呼ぶだけ
class DashboardController {
    public function index(ChatLogRepository $repo) {
        $hourly = $repo->fetchHourlyDistribution($start, $end); // DB分岐もRepository内で完結
    }
}
class ChatLogRepository extends AbstractRepository {
    public function fetchHourlyDistribution(\DateTimeImmutable $start, \DateTimeImmutable $end): array {
        // MySQL / Postgres / SQLite のHOUR分岐をここで一元管理
        // 0件の時間帯は array_fill(0,24,0) で補完して24件を返す
    }
}

この委譲で、Controllerは約200行ずつ痩せ、テストも 113 tests / 397 assertions がGREENに戻りました。レビューで REQUEST CHANGES をもらった直後の APPROVE WITH COMMENTS は、正直ほっとしました。

レビューは、時に厳しく、時にありがたいものです。

services.yaml の3つの流派。広く拾うか、狭く分けるか、1つずつ書くか

Routeと同じく、services.yaml もプラグインごとに流儀が違いました。23プラグインを調べると、3つの流派に分かれます。

流派1 — 広く一括で拾う(AiChatAssistant42方式)

# Resource/config/services.yaml — シンプルだが、やや広い
services:
    _defaults:
        autowire: true
        autoconfigure: true
        public: false

    Plugin\AiChatAssistant42\:
        resource: '../../*'
        exclude:
            - '../../Tests/'

../../* で Entity / Resource / Nav.php まで全部拾います。exclude が Tests/ だけで、他は除外していません。動きますが、厳密には過剰です。

流派2 — 狭く分けて拾う

# Resource/config/services.yaml — 精密に分割
services:
    Plugin\AiChatAssistant42\:
        resource: '../../*'
        exclude:
            - '../../Entity'        # Doctrineが管理するのでサービス化不要
            - '../../Resource'      # 設定ファイルなので除外
            - '../../Nav.php'       # サービスではない
            - '../../PluginManager.php'
            - '../../Tests/'
        autowire: true
        autoconfigure: true
        public: false

    Plugin\AiChatAssistant42\Controller\:
        resource: '../../Controller'
        public: true                # Controllerはpublic:trueが要る

    Plugin\AiChatAssistant42\Form\Type\Admin\:
        resource: '../../Form/Type/Admin'
        public: true

Entity はサービス化するとDoctrineと二重管理になるため除外するのが丁寧です。Controller と Form は別枠で public: true にしています。

流派3 — 1サービスずつ手書きする

# Resource/config/services.yaml — 1サービス1定義、resourceなし
services:
    Plugin\SalesReport42\Service\Ga4AuthService:
        lazy: true
        arguments:
            $clientId: '%ga4.client_id%'
        autowire: true
        public: false

    Plugin\SalesReport42\Service\Ga4ClientFactory:
        public: true # testで set() 置換するため public化(本番影響なし)

resource: を使わず、サービスごとに lazy / shared: false / factory まで細かく制御しています。テストでモックに置き換えるために public: true にするコメントまで残っているのが印象的でした。

学びと3つの反省

# 現状 他プラグインの作法 正解
1 resource: '../../*' が広すぎる Entity / Resource / Nav.php を除外 exclude に Entity / Resource / Nav.php / PluginManager.php を追加するのが丁寧です
2 ChatApiController だけ public: true Controller\ 全体を public: true、public: false のまま(autoconfigure に任せる) 全Controllerを public: true にするか、autoconfigure に任せて外すか、どちらかに統一するのが正解です
3 CacheBustExtension が tags なしで定義されている 他プラグインは tags: ['twig.extension'] / tags: ['kernel.event_subscriber'] が必須 Twig\Extension\CacheBustExtension は tags: ['twig.extension'] がないとTwigに認識されません。現状のままでは動かないデッドコードです(レポートの残課題 services.yaml:81 と一致)

services.yaml も、Routeと同じく「どこまでをサービス化するか」という配置の流儀だと学びました。広く拾う楽さと、狭く分ける正確さの間で、プラグインの性格に合わせて選ぶのが正解です。

services.yaml が不要な場合もある

調査で意外だったのは、他のプラグインは services.yaml を持っていないことです。

services.yaml なしでも動く例 中身 なぜ不要か
Coupon42 Service/CouponService.php はあるがyamlなし コンストラクタ引数がすべて型ヒントだけで解決できるため、Symfonyのautowireが自動で解決してくれる。var/cache/prod/ContainerOwx7K4n/getCouponServiceService.php を見ると、引数12個がすべて自動解決されている
Maker42 Service/ はあるがyamlなし シンプルなCRUDで、カスタム引数や tags が不要。Repository はDoctrineが自動登録するため、追加定義が要らない

つまり、 services.yaml が必要になるのは、次に当てはまるプラグインだけです。

  • カスタム引数を渡す(例: AiModelRegistry に ai_models.json のパスを渡す)
  • tags が要る(例: kernel.event_subscriber / console.command / twig.extension)
  • public: true や lazy: true など特別な制御が要る
  • parameters で環境変数を扱う(例: SalesReport42の GA4_PROPERTY_ID)

AiChatAssistant42は、上記すべてに当てはまるため services.yaml が必須でした。逆に、シンプルなプラグインなら services.yaml なしでも動きます。必要になってから置くのが、EC-CUBEの流儀だと学びました。


4. MCP、AIエージェント固有のはまりポイント。3プロバイダは3つの方言だった

ここが、AIプラグインならではの沼でした。OpenAI / Anthropic / Gemini は、似て非なるAPIを持っています。

max_tokens が通らない

// 誤り — gpt-5 / o1 / o3 では400エラーになる
$payload = ['model' => 'gpt-5', 'max_tokens' => 4096, 'reasoning_effort' => 'medium'];

// 正解 — モデル名で分岐し、Capability Matrixで reasoning を制御
// Service/AiAgent/OpenAiAgent.php buildRequestPayload()
if (str_starts_with($this->model, 'gpt-5') || str_starts_with($this->model, 'o1') || str_starts_with($this->model, 'o3')) {
    $payload['max_completion_tokens'] = $this->maxTokens; // gpt-5系はこちらのキー
} else {
    $payload['max_tokens'] = $this->maxTokens;
}
if (!empty($tools) && !$this->supportsReasoningWithTools()) {
    unset($payload['reasoning_effort']); // ツール併用でreasoning非対応なら外す
}

afa81bc〜6b6e2d1 で12組合せを疎通し、ai_models.json に supports_reasoning_with_tools を持たせて分岐させました。1つのキー違いで400が返るのは、AIプロバイダあるあるだと学びました。

空配列 [] が {} でないと怒られる

// 誤り — JSONで [] になり、Anthropic/Geminiが解釈できない
$schema = ['type' => 'object', 'properties' => []];

// 正解 — 空オブジェクトに正規化する
if (isset($schema['properties']) && $schema['properties'] === []) {
    $schema['properties'] = new \stdClass(); // json_encodeで {} になる
}

OpenAiAgent::convertToolsToOpenAiFormat() の小さな分岐ですが、これでAnthropicとGeminiのツール呼び出しが安定しました。地味ですが、効く修正です。

SQLiteの ESCAPE とGeminiの thoughtSignature

  • SQLiteでは ESCAPE 句の扱いがMySQLと違い、分岐が必要でした
  • Geminiでは thoughtSignature を往復で保持しないと、次のターンで文脈が切れます

どちらも「1プロバイダで動いたからOK」では見つからず、12組合せを回して初めて見えた不具合でした。AIエージェント開発では、プロバイダ横断の疎通を最初から回すのが正解だと痛感しました。


5. プラグイン汎用化、「内部で動く」を「誰でも動く」にする16コミット

公開配布のために、9月1〜2日の2日間で16コミットを集中させました。

モデル定義の外部化 — ai_models.json がなければ公開できなかった

// 正解 — モデルはコードに書かず、JSONに逃がす(Resource/config/ai_models.json)
{
  "version": "2.0.0",
  "providers": {
    "openai": {
      "models": [
        { "id": "gpt-5", "supports_tools": true,
          "supports_reasoning_with_tools": false, "cost_tier": "high" },
        { "id": "gpt-4o", "supports_tools": true,
          "supports_reasoning_with_tools": true, "cost_tier": "high",
          "is_default": true }
      ]
    }
  }
}

なぜ外部化が必要だったのか。一言で言えば、プロバイダーがモデルを勝手に更新するからです。開発中に起きた実例を挙げます。

  • gpt-5.6 系というIDで実装していたら、実在IDは gpt-5/gpt-5-mini/gpt-5-nano だった(92350f3 で /v1/models の実測値に修正)
  • claude-opus-5 と書いたら実在せず、正しくは claude-3-opus-20240229。しかも叩いたら404で、最終的にマトリクスから除外(3fc5934 で11→10モデルに)
  • gemini-2.0-flash は廃止済みで削除。残ったのも gemini-3.6-flash のような新IDに入れ替え

モデルIDをコードにハードコーディングしていたら、プロバイダーの更新のたびにリリースが必要になります。そこで Resource/config/ai_models.json に逃がし、Service/AiModelRegistry.php 経由で参照。さらに Service/AiModelSyncService.php で外部との同期処理を追加しました。モデルの追加・削除・差し替えが、コード修正なしでできる構成です。

モデルごとの差異。プロバイダーが違えば「同じチャット」は存在しない

実装で一番苦労したのは、モデルごとの差異です。「3プロバイダ対応」と一言で言いますが、同じリクエストを投げられるモデルは一つもありませんでした。

// gpt-5 / o1 / o3 系は max_tokens が使えない(OpenAiAgent.php)
if (preg_match('/^(gpt-5|o1|o3)/', $model)) {
    $payload['max_completion_tokens'] = $this->maxTokens; // 破壊的変更への対応
}
// gpt-5系は reasoning と tools の併用が不可 → Capabilityを見て reasoning_effort を落とす
  • OpenAI:gpt-5系は max_tokens ではなく max_completion_tokens を要求する破壊的変更。しかも推論モデルは reasoning と tools の併用不可のため、supports_reasoning_with_tools フラグを見て reasoning_effort を外す分岐が必要(6b6e2d1)
  • Gemini:thoughtSignature を捨てると会話が壊れる。表示用 thought と分離し、parts全体を透過的に往復させる round-trip 対応(e429019)。args: [] は {} に正規化しないと怒られる、という罠付きです
  • Anthropic:モデルIDの実在確認に振り回された上、未知のツールが来たらガードで弾く対応(e429019)

これらの差異を ai_models.json のフラグ(supports_tools/supports_reasoning_with_tools/cost_tier/is_default)に集約し、各Agentが参照する形に落ち着きました。プロバイダー追加は「JSONに1ブロック足す+Agentを1クラス書く」で済みます。

そして全モデル実機検証

極めつけは検証です。モデルごとに「あいさつ」と「商品おすすめ(ツール呼び出し)」の2問を投げ、全モデルのPASSを確認するシェルを作りました。

# bin/verify-10models.sh — 10モデル × 2問 = 20リクエストを実機に投げる
MODELS=("openai|gpt-5" "openai|gpt-5-mini" ... "gemini|gemini-3.5-flash-lite")
# gpt-5系は推論モデルのためタイムアウト120秒、他は60秒

verify-11models.sh は11モデル時代の旧版で、opus除外後に verify-10models.sh を新設し、旧版は参考用に残しています(3fc5934)。最終的に 10/10 PASS(simple+tool) を実機で確認してから申請しました。モックのGREENではなく、本物のAPIを叩いたGREENです。ここまでやって、ようやく「公開できる」と言えました。

ハードコードを ShopContextService に集約

// 誤り — thch-vape.shop 専用で、他店ではリンク切れ
$url = 'https://www.thch-vape.shop/products/detail/' . $id;
$from = 'no-reply@thch-vape.shop';

// 正解 — 実行環境から動的に解決する
class ShopContextService {
    public function getProductDetailUrl(int $productId): string {
        try {
            return $this->urlGenerator->generate('product_detail', ['id' => $productId], UrlGeneratorInterface::ABSOLUTE_URL);
        } catch (RouteNotFoundException) {
            return $this->buildAbsolutePath('/products/detail/' . $productId);
        }
    }
    public function getShopName(): string {
        return trim($this->baseInfoRepository->get()->getShopName() ?? '') ?: 'このショップ';
    }
}

ff6fd17 で thch-vape.shop 残存0件を確認しました。EasyArticle依存もこのとき完全除去し、どのEC-CUBEでも動く形にしました。

レート制限をセッションとIPで分離

// 誤り — セッションだけで制限すると、IPを変えれば突破できる
$count = $repo->countRecentBySession($sessionId, $since);

// 正解 — セッションとIPの二重で数える
$countSession = $repo->countRecentBySession($sessionId, $since);
$countIp = $repo->countRecentByIp($request->getClientIp(), $since);
// どちらかが閾値を超えれば制限。IPは trusted_proxies 対応の getClientIp() で取得
// Entity/ChatLog.client_ip に複合INDEXを追加

ChatLogRepository::countRecentBySession() と countRecentByIp() を分離し、 Version20260901000000 でマイグレーションしました。小さな改善ですが、公開するなら欠かせない対策です。

APIキーをAES-256-GCMで暗号化

// 誤り — 平文保存
$config->setApiKey($plainKey);

// 正解 — APP_SECRETで暗号化して保存、復号は必要なときだけ
$encrypted = $this->apiKeyEncryptor->encrypt($plainKey); // AES-256-GCM
$config->setApiKey($encrypted);
// 読み出し時: $this->apiKeyEncryptor->decrypt($config->getApiKey())

f54eb8b feat: encrypt API keys AES-256-GCM で対応しました。管理画面で平文が見えない安心感は、運用者にとって大きいものです。

パッケージング、Store審査

# 正解 — EC-CUBE Storeは vendor 同梱のtar.gz を求める
bin/package.sh # → AiChatAssistant42-1.0.0.tar.gz 1.5MB
# composer.json name を ec-cube/aichatassistant42 に変更(Store規約)
# PharData-safe化、prefixなしで展開できる形に

98d0ed6 と e220fb8 で直しました。Store審査は、コードの正しさだけでなく、パッケージの形まで見られるのだと学びました。

「内部で動く」を作るのが前半44件、「誰でも動く」を作るのが後半39件。二段階のしんどさはありますが、公開するならここまでやる必要があるのだな、と学びました。


6. 再度、EC-CUBEの流儀に対応、セキュリティ12項目と包みの作り直し

最後の山は、「公開前の総点検」でした。a1b7481 一件で、セキュリティ12項目とパッケージングを一気に固めています。issues #12・#13 の指摘対応です。

APIキーをURLから追い出す

// 誤り — キーがURLに残り、ログや履歴に漏れる
$client->get('https://generativelanguage.googleapis.com/v1beta/models?key=' . $apiKey);

// 正解 — ヘッダで送り、エラー文からも削る
$client->get($url, ['headers' => ['x-goog-api-key' => $apiKey]]);
// error_message とログからは key / api_key / Bearer を redact

Gemini の ?key= を x-goog-api-key ヘッダに移し、エラー文とログからキー類をマスクしました。当たり前のようで、リリース直前まで残っていた漏れです。

数値の上限を全部締める

// 誤り — 上限なしは、そのまま攻撃面になる
$qb->setMaxResults($limit);          // ProductRepository
$message = $request->get('message'); // 長さ無制限

// 正解 — 3箇所を締める
$limit = max(1, min((int) $limit, 100));  // H3: 取得件数は100止め
if (mb_strlen($message) > 2000) { /* 拒否 */ }  // H2: 本文は2000文字で切る
if ($iterations >= 10) { $logger->warning(...); break; }  // H4: ツール連鎖は10周で打ち切り

取得件数・メッセージ長・AIのツール呼び出し連鎖。いずれも「普通に使えば届かないが、悪意があれば届く」上限です。公開するなら、性善説の上限は全部 numbers で蓋をする。これがこの夜の流儀でした。

守るものを暗号化し、疑わしいものは正規化する

  • LINE のトークンと webhook ヘッダを暗号化(H5)。APIキーと同じ ApiKeyEncryptor(AES-256-GCM)で包み、送信時に復号。復号失敗時は平文を流さず null を返します(M2)
  • セッションIDを UUIDv4 に正規化(M1)。フロントは crypto.randomUUID() で発行し、不正なIDはログに残します
  • SSRF チェックを強化(M3)。角括弧の除去、16進・10進による回避、リダイレクト追従の無効化
  • CSRF トークンIDを統一(M4)。IDごとに接尾辞を付ける方式をやめ、定数に一本化

包み(パッケージング)も作り直す

# 誤り — 手作りの tar は、混入物と欠落に気づけない
tar -czf AiChatAssistant42-1.0.0.tar.gz .

# 正解 — 除外・包含を明示し、tar -tzf で検証する
./bin/package.sh  # → AiChatAssistant42-1.0.0.tar.gz 1.5MB
# vendor 同梱、prefix なし、必須4点(composer.json / eccube-plugin.yaml / services.yaml / README)の有無を自動検証

bin/package.sh を作り、除外・包含リストを明示して tar -tzf で検証する形にしました。Store審査はコードだけでなく包みの形まで見ます。このスクリプトが、後のv1.1.2再申請でも効いてきます。

「作る」の83コミットと「届ける」の51コミット。その境目にあるのがこの一夜でした。セキュリティの上限締めと包みの作り直し。地味ですが、公開とはこういう作業のことなのだと学びました。


12日間でどれだけAIにしゃべらせたか

せっかくなので、今回のプラグイン開発でどれだけAIを使ったかを、見積もって試算してみました。正確なトークンログは残していなかったため、Gitの追加行数からの推計です。

推計の前提

項目 実測値
追加行数 23,866行 + 汎用化 29,211行 = 53,077行
コミット数 実質83件(44件 + 汎用化39件)
セッション 初回10 + レビュー/修正で約30 = 約40セッション
前提 1行あたり15トークン、入出力比1:5

見積もった試算

区分 出力トークン 入力トークン 合計
コード生成 53,077行 × 15トークン 約80万 約400万 約480万トークン
うち初回73ファイル13,189行だけで 約20万 約100万 約120万トークンを一晩で消費
1コミットあたり — — 約58,000トークン
1セッションあたり — — 約120,000トークン

人が書けば1人月を超える53k行を、12日間で回せたのは、AIの力を借りたからこそだと感じています。

モデル別のコスト感

OpenCodeを起点に、muse-spark-1.2 が協働した構成として試算しています。

モデル想定 単価(入力/出力 $/1M) 480万トークンでの概算
OpenCode(オーケストレーション) — セッション管理・差分生成の起点。トークンは下記モデルに計上
muse-spark-1.2(メイン実装) $3 / $15 約$28〜35
Claude Sonnet 4 系(レビュー/リファクタ) $3 / $15 約$28〜35
GPT-4o 系(比較) $2.5 / $10 約$22〜28
Gemini 1.5 Pro 系(比較) $1.25 / $5 約$12〜18

OpenCode自体は課金がなく、各セッションで呼び出す muse-spark や Serena のAPI利用にトークンがかかります。上記は「すべてを同一モデルで賄った場合」の目安です。実際は複数モデルを混在させているため、単純な合計ではありません。

実感でいうと

今回、73ファイルを一晩で生んだ勢いと、その後の82件の地味な修正を両方経験して、同じことを感じました。負債は「悪いコード」だけではなく、「決め切らずに積み上がった前提」そのものなのだと。

名前空間、ルーティング、DQL、CSRF、jQuery、永続化、ライセンス、同期、DB移行、汎用化。どれも、最初に決め切れば小さな話ですが、後から直すと何倍にも膨らみます。だからこそ、日報に残し、レビューで指摘し、ログで検証する。地味なことを、丁寧に繰り返すしかないのだと思います。

私たちもまだ道半ばです。残課を一つずつ潰し、composer.json name や tar のStore対応を還元していきます。

最後まで読んでいただき、ありがとうございます。もしEC-CUBEで同じ壁にぶつかった方がいれば、何かのヒントになればうれしいです。

公開後日談:AIチャットアシスタント、オーナーズストアで公開されました

公開ページ: https://www.ec-cube.net/products/detail.php?product_id=3607

9月2日、公開版を申請しました。前編で書いた83コミットの先です。「あとは待つだけ」と思ったのが甘かった。返ってきたフィードバックで一番重かったのは、機能の感想ではなく「インストール不具合」でした。動くコードより先に、届くパッケージ。そこが一番の反省点です。

なお、v1.0.1からv1.1.2までのバージョンアップは、すべて軽微な修正の積み重ねです。大きな新機能はありません。だからこそ、インストールでつまずかせるのは一番やってはいけないことでした。

一番の反省:インストールでつまずかせた

不具合1:有効化・更新時にアセットがコピーされない

開発環境では動くのに、本番でプラグインを有効化すると assets が配置されず、チャットUIが壊れる問題がありました。原因は PluginManager.php でのコピー処理漏れで、有効化・更新時に明示的にコピーするよう修正しました(8ce4783)。「開発では動いた」は何の保証にもならない、という基本を思い出させられました。

不具合2:インストール時の依存と autowire エラー(store-3607)

インストール直後に落ちる報告があり、原因は二つありました。一つは不要な root 依存、もう一つは ContainerInterface の autowire 不整合です。修正とあわせて docker compose によるインストールテストを追加し(dc55c97)、「インストールできること」自体をテストで担保するようにしました。インストールの検証を人の手に任せていたのが間違いでした。

不具合3:tar の形が悪くてインストールできない

Store に上げる tar.gz の形が規約外で、展開時に失敗する問題です。アーカイブ直下に composer.json を置く(prefix なし)、PharData-safe に固める、で対応しました(e220fb8)。コードが正しくても、包みが悪いと届きません。composer.json の name も Store 規約に合わせています(98d0ed6)。

不具合4:SQLite 互換性

検証環境の SQLite で動かない箇所があり、互換性修正を入れました(846214d)。MySQL 本番だけ見ていると、こういうところで足をすくわれます。

敗因:AIのテストを過信し、UATを怠った

ここまで書いてきて、正直に告白します。根本原因は技術ではありません。AIのテストを過信し、人間によるUAT(受入テスト)を怠ったことです。

開発中、AIは php -l も unit テスト(183件)も回し、すべてGREENでした。「テストが通っているから大丈夫」という空気が、いつの間にかチームに流れていました。でも unit テストが見るのは、プラグインが「ある」前提の世界です。インストール直後の、プラグインが「ない」状態から「ある」状態に変わる瞬間を、誰も人間の手で触っていなかった。それがインストール不具合の山です。

対策として、プラグイン未インストールの素のEC-CUBEをDockerで立ち上げ、そこにプラグインを入れる結合試験を作りました(docker-compose.verify.yml+bin/verify-docker-install.sh)。EC-CUBE 4.2と4.3の両方で eccube:plugin:install を回し、審査で落ちた2件(root依存・autowire不整合)をローカルゲートで事前に弾く仕組みです。AIのテストは速い。でも「初めて触る人の手」を再現するのは、人間が用意した環境だけです。

公式資料は最初に読む

もう一つの反省は、公式資料の読み直しが遅かったことです。審査に刺されてから慌てて読み直しました。これからプラグインを作る方は、コードを書く前にまず以下の公式資料を確認してください。私たちのように、審査で学ぶのは遠回りです。

特に plugin_spec はPDFですらなく、オンライン文書として更新され続けています。今回の description 問題も、最初にここを読んでいれば防げました。

オーナーズストアで公開:product_id=3607

修正版 v1.1.2 で再申請し、無事公開されました。9月2日の申請から数えて、公開までの追加は実質51コミット。83コミットで「作った」話の続きに、51コミットの「届けた」話が付いたことになります。

開発中に見つけたバグと、ついでに足した機能

バグ1:検索が全滅する ESCAPE 一文字(v1.1.1)

商品キーワード検索が MariaDB で全滅しました。エラーは 1064。原因は LIKE ... ESCAPE '\' のバックスラッシュ一文字で、正解は ESCAPE '\\' の二重化です。一文字で検索全体が死ぬ。DB系の修正は本番のデータでしか顔を出さないことがあり、実機検証の大切さを痛感しました。

バグ2:IME変換中のEnterで誤送信(v1.1.1)

日本語入力中にEnterで変換を確定したつもりが、そのまま送信されてしまう問題です。compositionstart/end と e.isComposing || keyCode 229 のガードで対応しました。日本語を使うチャットUIなら必ず踏む罠だと思います。

バグ3:分の境目で落ちるレート制限テスト(v1.1.0)

MCP サーバーのレート制限テストが、たまに落ちるフレークでした。原因は「分」をまたぐタイミング。上限を240に緩和し、分跨ぎを警告扱いにして安定させました。時刻に依存するテストは、最初から「跨ぐ前提」で書くのが正解です。

バグ4:管理画面のDI不整合と有効化時のアセット未コピー(v1.0.1)

DesignController のDI不整合と、プラグイン有効化・更新時にアセットがコピーされない問題を修正しました。開発環境では動くのに本番で壊れる典型例で、PluginManager.php でのコピー処理が決め手です。

機能1:Web MCP対応(v1.1.0)

Streamable HTTP の MCP サーバーを実装しました。POST /mcp(initialize/tools/list/tools/call の7ツール)と GET /.well-known/mcp.json による Discovery、IP別レート制限付きです。AIエージェントから商品情報を直接叩けるようになり、このプラグインの目玉機能になりました。

機能2:MCP監査ログ+ツール失敗の分離(fork取込)

本番サイト用カスタム版との差分調査で見つけた改善を取り込みました。MCP の呼び出しを監査ログに残し(IPはハッシュ化でプライバシー配慮)、1つのツールの失敗が全体を道連れにしない分離対応、タグ未指定時のガードです。他現場のコードとの差分調査は、思わぬ拾い物があるものです。

機能3:APIキーの暗号化とアセット軽量化(v1.0.1〜v1.1.1)

管理画面に保存する API キーを AES-256-GCM で暗号化し、JS/CSS を esbuild で minify しました。公開する以上、平文保存と重いアセットは置いていけないという判断です。

振り返ると、審査は敵ではありませんでした。管理画面に説明文がプラグイン名として出る未来も、4.2の名前で4.3に対応する矛盾も、全部「公開後に自分で恥をかく案件」です。刺してもらえてよかった。それが正直な気持ちです。


参考資料

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?