サービス
Lase が提供するサービスクラスの一覧です。すべて CodebaseJp\Lase\Services 名前空間に属します。
SubscriptionService
サブスクリプションの作成・更新を担当します。
createSubscription(Tenant $tenant, Plan $plan, Collection $options)
テナントに対してサブスクリプションを作成します。
- 悲観的ロックでテナントをロックし、競合状態を防止
- 現在のプランよりダウングレードする場合、利用量が新しい上限を超えていないかチェック
SubscriptionQuota・SubscriptionFeature・GrantedQuota・GrantedFeatureを自動同期- DB トランザクション内で実行
recordUsage(string $tenantId, string $metric, int $value)
利用を記録します。サブスクリプションの上限まで SubscriptionUsage に加え、溢れた分を GrantedUsage(恒久枠)に加えます。合計の上限を超える場合は例外を投げ、何も記録しません。
- テナントの行をロックして記録するため、同じテナントの記録が並行しても加算は失われません
usageSummary(Tenant $tenant) / setUsage(string $tenantId, string $metric, string $scope, int $value)
管理画面のご利用状況で、利用上限と利用数を確かめ、記録の誤りを直すために使います。
usageSummary()は、契約の枠(現在のサブスクリプション)と恒久枠(GrantedQuota・GrantedUsage)の上限と利用数を、指標ごとに返します。読み取りだけで、行は作りませんsetUsage()は、scope(subscription/granted)の利用数を指定した値にし、前後の値を返します。上限は変えません。テナントの行をロックしてから直すため、同時に記録される利用とぶつかりません。契約の無いテナントの契約の利用数は直せません- 管理画面からは、全権限の管理者だけが直せます(
PUT /api/admin/tenants/{id}/usages)。理由と前後の値は操作ログ(ADMIN_UPDATE_USAGE)に残ります
releaseUsage(string $tenantId, string $metric, int $value, CarbonInterface $recordedAt)
recordUsage() で記録した利用のうち、実際には使わなかった分を取り消します。たとえば送信を始める時点で件数分を記録し、送信に失敗した分をあとから返す、という使い方をします。
recordUsage()と逆の順に、GrantedUsageから返し、残りをSubscriptionUsageから返します。合計が同じなら、はじめから少なく記録した場合と同じ内訳になりますGrantedUsageから返すのは、今月のサブスクリプションの上限を使い切っているときだけです。GrantedUsageは月をまたいで残るため、上限を使い切っていない月に返すと前月までの分を返してしまいます。ただしGrantedUsageには月ごとの内訳がないため、上限を使い切った月に今月恒久枠を使った数より多く返すと、前月までの分も返ります- 使用量より多くは返しません。戻り値は実際に取り消した数です
$recordedAt(記録した日時)が現在のサブスクリプションの月より前なら何もせず0を返します。月が替わると使用量は 0 から数え直し、前月分は従量課金として Senter に送り済みのためです。月の途中のプラン変更では使用量を引き継ぐため、同じ月なら返します
// 送信を始めるときに件数分を記録する
$service->recordUsage($tenant->id, 'EMAIL_SENT_COUNT', $count);
// 配信結果が確定したら、送信できなかった分を返す
$service->releaseUsage($tenant->id, 'EMAIL_SENT_COUNT', $failedCount, $startedAt);RegistrationPlanResolver
申込みフォームから登録するテナントに付けるプランの規則(RegistrationPlanRule)を決めます。
resolve(string $tenantCode, ?string $partnerId): ?RegistrationPlanRule
有効な規則を priority の小さい順(同じなら作った順)に見て、最初に合った規則を返します。どれにも合わなければ null です(呼ぶ側はトライアルのプランにします)。規則のプランが有効でないときは、その規則を飛ばして次を見ます。詳しくは 運用 を参照してください。
BusinessCalendar
組織の暦(営業する曜日・休日・終業時刻)で、営業日と期限を求めます。営業日は、営業する曜日のうち、休日でない日です。暦は組織の設定の「営業日と休日」で決めます。
$calendar = BusinessCalendar::forTenant($tenantId);
$calendar->isBusinessDay(Carbon::parse('2026-11-03')); // 休日なら false
$calendar->deadline($now, 2); // 2営業日目の終業時刻(0 ならその営業日の終業時刻)
$calendar->addBusinessDays($date, -1); // 営業日で足し引きする(時刻はそのまま)
$calendar->deadlineFromValue('2026-11-30'); // 日付ならその日の終業時刻、日時ならそのまま組織を引かずに作ることもできます(new BusinessCalendar([1, 2, 3, 4, 5], '18:00', ['2026-11-03']))。
AuthService
認証周りのロジックを担当します。アカウントの有効化・パスワードリセット・トークン発行などを処理します。
TenantSuspensionService
テナントの緊急停止と解除を担当します。管理画面からの手動操作と、将来の自動停止(不正検知など)の両方から同じ経路を通すために切り出しています。
suspend(Tenant $tenant, string $reason, ?string $adminUserId, ?Request $request)
テナントを緊急停止します。保存により tenant:{code} のキャッシュが破棄されるため、次のリクエストから遮断されます。
$reasonは運用の記録用です。テナント側の画面には出しません$adminUserIdを渡した場合のみアクティビティログ(ADMIN_SUSPEND_TENANT)を記録します- 契約は維持されるため、サブスクリプションの更新は停止中も行われます
resume(Tenant $tenant, ?string $adminUserId, ?Request $request)
緊急停止を解除します。停止の履歴はアクティビティログに残るため、テナント側の停止情報(suspended_at など)は消します。
// 操作者がいない自動停止では、監査の user_id が必須のためアクティビティログは残りません
$service->suspend($tenant, '不審なアクセスを検知');TenantScreeningService
テナント登録時に、生成AI(OpenAI の Responses API)で登録内容の疑わしさ(ブランドの騙り・迷惑登録)を審査します。公開の登録フォームからの登録で、登録の確定後にキュー(Jobs\ScreenTenantRegistration)から呼ばれます。管理画面から作ったテナントは審査しません。
- 判定は記録するだけで、テナントを自動で止めません。 疑わしいもの(
REVIEW/ERROR)は管理画面の「登録審査」に出し、運営がテナントの詳細画面で緊急停止するかを決めます - AI に渡すのはテナント名・テナントコード・メールアドレスのドメインだけです。担当者名・電話番号・メールアドレスのローカル部は送りません
- 審査対象はデータ(JSON)として渡し、出力は JSON Schema の構造化出力で受けます。登録内容に埋め込まれた AI への指示には従わせず、その記述があれば点数によらず
REVIEWにします - 呼び出しの失敗・タイムアウト・形の崩れた応答は素通しにせず
ERROR(運営の確認)として記録します
screen(Tenant $tenant): ?TenantScreening
審査して tenant_screenings に記録します。lase.tenant_screening.enabled が無効なら何もせず null を返します。
decide(int $riskScore, array $categories): TenantScreeningVerdict
点数が lase.tenant_screening.review_threshold(既定 50)以上、または観点に PROMPT_INJECTION を含めば REVIEW、それ以外は PASS です。
TenantUserRoleService
テナントユーザーへの役割の割り当てを担当します(役割は1人1つ)。
assign(TenantUser $tenantUser, ?string $tenantRoleId)
役割を割り当てます(null なら役割を外します)。権限はリクエストをまたいでキャッシュされるため、変更後に TenantAuthorizer::invalidateCache() を呼びます。
アプリ側で独自のユーザー更新処理を持つ場合も、キャッシュの破棄漏れを防ぐためにこのサービスを使ってください。役割を変更してよいかの判定は TenantUserPolicy::changeRole と TenantRolePolicy::assign が持ちます(Lase 内ではテナント側・管理側の TenantUserController::update が両方このサービスを使います。管理側はポリシーによる確認を行わないため、オーナーの保護はテナント側の経路にのみ効きます)。
$this->authorize('changeRole', $tenantUser);
if ($tenantRoleId !== null) {
$this->authorize('assign', TenantRole::findOrFail($tenantRoleId));
}
$service->assign($tenantUser, $tenantRoleId);アプリが自分で登録した役割は TenantRole::findByCode() で引いて割り当てます。
$service->assign($tenantUser, TenantRole::findByCode('snaps.admin')->id);TenantLoginTokenService
メインドメインでログイン済みのユーザーを、テナントのサブドメインへログインさせるワンタイムトークン(1分間有効)を扱います。POST /api/auth/tenants/{tenant}/issue-login-token と POST /api/auth/login-by-token はこのサービスを使います。
issue(User $user, Tenant $tenant, bool $remember = false, ?string $actor = null): string
トークンを発行します。緊急停止中のテナントでは TenantSuspendedException、ログインする資格が無ければ AuthorizationException を投げます。
$actorを省略した場合: テナントにアクティブに所属(TenantUser)するユーザーだけが対象です$actorを指定した場合:config('lase.tenant_login_actors')に登録したTenantLoginActorの判定で、所属していないユーザーもログインさせます(設定を参照)
consume(string $token, ?Tenant $tenant): ?array
トークンを使い切り、['user' => User, 'remember' => bool] を返します。無効なトークン、別テナント向けのトークン、発行後に資格を失ったユーザー(所属の利用停止や立場の判定を通らなくなった場合)は null を返します。
AnalyticsService
管理画面向けの統計データを集計します。
- テナント数・ユーザー数・お問い合わせ数のサマリー
- 日次・月次の時系列データ生成
RecaptchaService
Google reCAPTCHA v3 のトークン検証を行います。
$recaptchaService->verify(string $token): boolSenterService
外部課金サービス「Senter」との API 通信を担当します。
- サービスプラン・オプション・アドオン情報の取得
- 請求書の取得
- 未請求利用量の取得
- Stripe 連携(顧客作成・SetupIntent・支払方法管理)
ensureTenantRegistered(Tenant $tenant)
テナントが Senter に顧客と契約を持つようにします。どのテナントも Senter に顧客と契約を持つ前提のため、Senter と連携する前に作ったテナントなど、欠けているものだけをここで作ります。
- サービス利用(請求のもと)は作りません。請求が始まるのは、テナントがプランを選んだとき(
SubscriptionService::createSubscription()。Senter にサービス利用が無ければ、更新ではなく新しく作ります) - 確かめたテナントは1日覚えておき、そのあいだは Senter に問い合わせません
- 同時に補わないよう、テナントごとにロックします(キャッシュのドライバーがロックに対応している必要があります)
契約の画面の API の前では、tenant.senter ミドルウェアがこれを呼びます。
ActivityLogService
操作ログの記録を担当します。
record(BackedEnum $type, int $userId, ?int $tenantId, ?Request $request = null)
アクティビティログを1件記録します。
$type: ログの種別(ActivityLogTypeEnum の値)$userId: 操作を行ったユーザーの ID$tenantId: テナント ID(テナントコンテキスト外ではnull)$request: HTTP リクエスト(省略時は IP・UA が記録されない。スケジューラー・キュー等の非 HTTP コンテキストから呼ぶ場合は省略可)
recordForTenant(BackedEnum $type, string $summary, array $metadata = [], ?Request $request = null)
ログイン中の人が、ログイン中の組織で行った操作を記録します(組織の操作ログに出ます)。操作した人と組織はリクエストから取り、どちらかが無ければ記録しません。$summary(何に対して行ったか)は metadata.summary に入り、組織の操作ログの画面に出ます。
AiEmbeddingService
ドキュメントをベクトル化してデータベースに保存し、AIチャットの RAG(検索拡張生成)を実現するサービスです。
インデックスの作成は php artisan lase:index-docs コマンドで実行します。
主なメソッド
| メソッド | 説明 |
|---|---|
chunkText() | Markdown をチャンクに分割する。Embeddings API に依存しない純粋関数 |
isIndexed() | 同一内容で既にインデックス済みかを返す |
indexDocument() | チャンク分割 → Embedding 生成 → DB 保存を行う |
findRelevantChunks() | クエリに近いチャンクを類似度の高い順に返す |
pruneExcept() | 指定ソース一覧に含まれない孤立チャンクを削除する |
チャンク分割の方針
- 先頭の YAML フロントマターは除去し、本文のみを対象にする
- 見出し(
#〜###)単位に分割する。コードブロック(```/~~~)内の#は見出しとして扱わない - 各チャンクの先頭に見出しパス(例:
ログイン > パスワードを忘れた場合)を付与し、チャンク単体でも文脈が分かるようにする - 上限文字数を超えるセクションは段落単位で分割し、チャンク間に
chunk_overlap文字分の重複を持たせる
検索の仕様(ハイブリッド検索)
ベクトル検索と全文検索の結果を RRF(Reciprocal Rank Fusion) で融合します。
score = Σ 1 / (60 + 各検索での順位)ベクトル検索は意味的な近さに強い一方、製品名・エラーコードのような固有語の完全一致に弱いという弱点があります。全文検索(MySQL の ngram パーサ)を併用することで、たとえば「E4021」のような語がベクトル的に一致しないチャンクも拾えるようになります。hybrid_enabled を false にするとベクトル検索のみになります。
ベクトルは正規化(ノルム1)して保存するため、類似度計算は内積のみで済みます。similarity_threshold を下回るチャンクは無関係とみなして除外し、無関係な文書がプロンプトへ混入するのを防ぎます。クエリ側の Embedding は query_cache_ttl 秒間キャッシュされます。
WARNING
InnoDB の全文検索インデックスは未コミットの行を参照できません。インデックス作成と同一トランザクション内で検索した場合、全文検索側は0件となりベクトル検索のみに縮退します(テストで RefreshDatabase を使う場合が該当します)。
WARNING
Embedding 生成に失敗した場合は例外を投げます(ゼロベクトルを保存してインデックスを黙って壊さないため)。indexDocument() は Embedding 生成を完了させてから既存チャンクを差し替えるため、API 失敗時に既存インデックスを失うことはありません。
ベクトルの保存形式
embedding は正規化済みベクトルを float32 で並べた BLOB です。JSON 文字列に比べて次の効果があります(実測値)。
| 1536次元 JSON | 512次元 float32 | |
|---|---|---|
| 1チャンクあたり | 32,794 B | 2,048 B |
| デコード(1000件) | 77.4 ms | 8.2 ms |
次元数の削減は text-embedding-3-* の Matryoshka 表現を利用しており、1536→512 程度では検索精度の低下はごく僅かです。合わせて 約1/16のサイズ・約9.5倍のデコード速度になります。
embedding_model / dimensions を各行に保持しているため、モデルや次元数を変更すると:
- 既存チャンクは SQL の段階で検索対象から除外されます(PHP 側で0点にするのではなく)
isIndexed()がfalseを返すため、--forceなしで自動的に再インデックスされます
設定
config/lase.php の ai セクションで調整できます。
| キー | 既定値 | 説明 |
|---|---|---|
embedding_model | text-embedding-3-small | Embedding 生成に使うモデル |
embedding_dimensions | 512 | ベクトルの次元数。0 でモデル既定値(次元数指定に非対応のモデル向け) |
embedding_batch_size | 50 | 1リクエストにまとめるチャンク数 |
chunk_max_length | 1500 | チャンクの上限文字数 |
chunk_min_length | 20 | これ以下のチャンクは破棄する |
chunk_overlap | 100 | 分割時に重複させる文字数 |
top_k | 5 | 検索で取得するチャンク数 |
similarity_threshold | 0.3 | この類似度を下回るチャンクは除外する |
hybrid_enabled | true | 全文検索を併用し RRF で融合する |
candidate_multiplier | 4 | 融合前に各検索から取得する候補数の倍率(top_k × この値) |
query_cache_ttl | 3600 | クエリ Embedding のキャッシュ秒数(0 で無効) |