2 つの製品ライン、同一コードベース。fork による 2 つの保守地獄を作らず、UI / データ / ビジネスロジックを厳格に分離するには?
百原 GEO Platform の主製品は企業 SaaS(geo.baiyuan.io)、ブランドマーケティングチーム向け。2026 年 4 月に第 2 製品ライン「百原 ME」(me.baiyuan.io)を追加、個人 IP / KOL / 公人物向け AI イメージ管理。
両方とも同じコアエンジンを共有(15 大 AI プラットフォームスキャン、スコアリング、AXP シャドウドキュメント、RAG)、ただし:
Organization)、ME は「人物実体」(Person)creator_profile / talk_topics / future_plans を加えて計 23最も直接的な方法は repo を fork することだが、2 つの悪い結果がある:
そこで単一コードベース複数分岐アーキテクチャを選んだ。
brands テーブルにカラム追加:
ALTER TABLE brands ADD COLUMN brand_type TEXT
NOT NULL DEFAULT 'enterprise'
CHECK (brand_type IN ('enterprise', 'personal_ip'));
各ブランドは作成時に分類。personal_ip の判定:
me.*(SSR が host header を読む)下流のすべての query が brand_type でフィルタ。
SQL フィルタだけでは不十分 — フロントエンドが API 呼び出し時に正しいセグメントを伝える必要がある:
function getBrandSegment(): 'personal' | 'enterprise' {
if (typeof window === 'undefined') return 'enterprise';
return window.location.hostname.toLowerCase().startsWith('me.')
? 'personal' : 'enterprise';
}
// 全 fetch に X-Brand-Segment header をセット
backend middleware が header を解析、request scope に書き込み。すべての controller の brand リスト query に自動でフィルタ条件を追加。スーパー管理者 override で skip 可能。
Next.js root app/layout.tsx のデフォルト export const metadata は build-time 静的。問題:同じ /dashboard が me.* と geo.* で同じタイトルを使い、ME ユーザーのタブに「GEO Platform」と表示される。
解決:generateMetadata async function で各 request 毎に host header を読む。
OG image、Twitter card、canonical URL もすべてこれに従う。
新機能ごとに 4 層で検証:
| 層 | チェック項目 | 違反症状 |
|---|---|---|
| データ層 | すべての brands query に brand_type 条件 |
ME ユーザーが GEO の brand list を見る |
| API 層 | すべての controller が req.brandSegment を使用 |
API レスポンスに異セグメントの brand が混入 |
| UI 層 | フォント / テーマ / コピーがホスト名で切り替わる | ME 上に GEO の「7 日無料トライアル」CTA が表示 |
| SEO 層 | generateMetadata がホスト対応、robots.txt / sitemap.xml も対応 |
タブタイトルや OG image がブランドを跨ぐ |
新機能の PR がこの 4 層レビューを通過必須。社内 feedback_no_whack_a_mole ルールに記録。
ME は [data-theme="personal"] スコープ、GEO は [data-theme="geo-light"] / [data-theme="geo-wine"]。初期実装で ME の --font-noto-serif-tc を :root に書いてしまい、GEO body も serif フォントを継承。修正:ME 専用変数は必ず [data-theme="personal"] { ... } 内にスコープ、:root には両製品ラインで共用するトークンのみ。
/dashboard は両製品ラインの共用ルート。Sidebar logo / アカウント badge / 月次レポート設定などの要素はブランド対応の切り替えが必要。実装で personalMode prop を追加:
const personalMode = isPersonalIp || isPersonalHost;
{personalMode ? <MeLogo /> : <BrandLogo />}
新しい dashboard 子ページ要素を追加するときに忘れがち — ESLint rule または PR テンプレートのチェックリストに記載。
cron の SQL に brand_type フィルタが無いと、ME 月次レポートが GEO 顧客に送信される。修正:cron worker は brand_visual_configs を brands と JOIN して brand_type を取得、言語別メールテンプレートに dispatch。
PAYUNi webhook は brand_id を持つが、初期 handler は brand_type をチェックしなかった。ME ユーザーのキャンセルが GEO の返金規則(7 日クーリングオフ + 2.8% 手数料)を発動 — しかし ME は招待制で 7 日クーリングオフの概念なし。修正:webhook handler の最初で brand_type を読み、mePaymentHandler または geoPaymentHandler に dispatch。
brand_type カラムが分岐の根:ブランド作成時にセット、不可変X-Brand-Segment HTTP ヘッダーがリクエスト層シグナル:フロントエンドがホスト名で設定、バックエンド middleware が解析generateMetadata host-aware が SEO 層のタブタイトル混乱を解決brand_type フィルタを忘れがち、PR checklist に追加ナビゲーション:← 付録 D:図表索引 · 📖 目次