diff --git a/backend/alembic_migrations/versions/0050_create_agent_daily_usage.py b/backend/alembic_migrations/versions/0050_create_agent_daily_usage.py new file mode 100644 index 00000000..616b70ad --- /dev/null +++ b/backend/alembic_migrations/versions/0050_create_agent_daily_usage.py @@ -0,0 +1,43 @@ +"""create agent_daily_usage table(Agent 日次レート制限 / #521・ADR-0023) + +プリペイド課金の残高チェックに代わる abuse 防止として、Agent エンドポイントの +ユーザ×日ごとのリクエスト回数を保持する原子的カウンタテーブルを作成する。 + +Revision ID: 0050_create_agent_daily_usage +Revises: 0049_drop_blog_tables +Create Date: 2026-07-21 00:00:00.000000 +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "0050_create_agent_daily_usage" +down_revision: Union[str, None] = "0049_drop_blog_tables" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "agent_daily_usage", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("user_id", sa.String(length=36), nullable=False), + sa.Column("usage_date", sa.Date(), nullable=False), + sa.Column("request_count", sa.Integer(), nullable=False, server_default="0"), + sa.Column( + "created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False + ), + sa.Column( + "updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False + ), + sa.PrimaryKeyConstraint("id"), + sa.ForeignKeyConstraint(["user_id"], ["users.id"]), + sa.UniqueConstraint("user_id", "usage_date", name="uq_agent_daily_usage_user_date"), + ) + + +def downgrade() -> None: + op.drop_table("agent_daily_usage") diff --git a/backend/app/core/env_keys.py b/backend/app/core/env_keys.py index 8ab674ed..a708ed3a 100644 --- a/backend/app/core/env_keys.py +++ b/backend/app/core/env_keys.py @@ -127,6 +127,11 @@ # ローカル Ollama 呼び出しの HTTP タイムアウト秒数(既定 300。ローカル開発専用) OLLAMA_TIMEOUT_SECONDS = "OLLAMA_TIMEOUT_SECONDS" +# Agent エンドポイントのユーザ単位日次リクエスト上限(#521 / ADR-0023)。 +# プリペイド課金の残高チェックに代わる abuse 防止。未設定時は既定値 +# (services/agent/rate_limit.py の DEFAULT_AGENT_DAILY_LIMIT)を使う。 +AGENT_DAILY_LIMIT = "AGENT_DAILY_LIMIT" + # --- 決済(Stripe Checkout / ADR-0012 Phase 2) --- # 本番(Cloud Run)では Secret Manager から注入する。ログ出力禁止 diff --git a/backend/app/core/errors.py b/backend/app/core/errors.py index c909cb79..3b053b14 100644 --- a/backend/app/core/errors.py +++ b/backend/app/core/errors.py @@ -37,6 +37,8 @@ class ErrorCode(str, Enum): # Agent(LLM チャット / ADR-0010) AGENT_LLM_ERROR = "AGENT_LLM_ERROR" AGENT_PARSE_ERROR = "AGENT_PARSE_ERROR" + # Agent の日次利用上限(#521 / ADR-0023) + AGENT_DAILY_LIMIT_EXCEEDED = "AGENT_DAILY_LIMIT_EXCEEDED" # 課金(プリペイドクレジット / ADR-0012) INSUFFICIENT_CREDITS = "INSUFFICIENT_CREDITS" # 決済(Stripe Checkout / ADR-0012 Phase 2) diff --git a/backend/app/messages.json b/backend/app/messages.json index 853d88d3..46fa02e3 100644 --- a/backend/app/messages.json +++ b/backend/app/messages.json @@ -63,6 +63,7 @@ "agent": { "llm_failed": "AI の応答取得に失敗しました。しばらくしてからもう一度お試しください。", "parse_failed": "AI の応答を解釈できませんでした。もう一度お試しください。", + "daily_limit_exceeded": "本日の AI 利用回数の上限に達しました。明日また利用できます。", "target_required": "このスコープでは対象の指定が必要です。", "target_not_found": "指定された対象が見つかりません。", "draft_link_required": "経歴書ドラフトの生成に必要な GitHub 連携データがありません。GitHub 連携を実行してから再度お試しください。", diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 8104f5bb..9ede5835 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -1,5 +1,6 @@ """SQLAlchemy モデル。""" +from .agent_usage import AgentDailyUsage from .billing import AgentUsageLog, CreditTransaction from .cache import GitHubLinkCache, ResumeDraftCache from .master_data import MQualification, MTechnologyStack @@ -24,6 +25,7 @@ from .user import User __all__ = [ + "AgentDailyUsage", "AgentUsageLog", "CreditTransaction", "GitHubLinkCache", diff --git a/backend/app/models/agent_usage.py b/backend/app/models/agent_usage.py new file mode 100644 index 00000000..422bfd2d --- /dev/null +++ b/backend/app/models/agent_usage.py @@ -0,0 +1,46 @@ +"""DevForge Agent の利用状況モデル。 + +ADR-0023(Haiku 無料一本化)で、プリペイド課金の残高チェックに代わる abuse 防止として +ユーザ単位の日次レート制限を導入する(#521)。本テーブルはユーザ×日ごとのリクエスト回数を +1 行で保持する原子的カウンタ。 +""" + +import uuid +from datetime import date, datetime + +from sqlalchemy import Date, DateTime, ForeignKey, Integer, String, UniqueConstraint, func +from sqlalchemy.orm import Mapped, mapped_column + +from ..db import Base + + +class AgentDailyUsage(Base): + """Agent エンドポイントのユーザ×日ごとのリクエスト回数(日次レート制限用)。 + + `usage_date` は JST 基準の日付。ユーザ×日で 1 行に集約し、`request_count` を + 原子的 UPDATE で増やす(Cloud Run 単一インスタンス前提 / ADR-0005)。 + """ + + __tablename__ = "agent_daily_usage" + __table_args__ = ( + UniqueConstraint("user_id", "usage_date", name="uq_agent_daily_usage_user_date"), + ) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) + user_id: Mapped[str] = mapped_column( + String(36), ForeignKey("users.id"), nullable=False + ) + usage_date: Mapped[date] = mapped_column(Date, nullable=False) + request_count: Mapped[int] = mapped_column( + Integer, nullable=False, default=0, server_default="0" + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=func.now(), server_default=func.now(), nullable=False + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=func.now(), + server_default=func.now(), + onupdate=func.now(), + nullable=False, + ) diff --git a/backend/app/repositories/agent_rate_limit.py b/backend/app/repositories/agent_rate_limit.py new file mode 100644 index 00000000..815f1ed4 --- /dev/null +++ b/backend/app/repositories/agent_rate_limit.py @@ -0,0 +1,69 @@ +"""Agent 日次レート制限のカウンタ永続化(#521 / ADR-0023)。 + +ユーザ×日ごとの `request_count` を原子的に増やす。Cloud Run 単一インスタンス +(ADR-0005)前提のため分散ロックは不要。IntegrityError(同一キーの競合)は +再取得で吸収する(`.claude/rules/backend/database.md`)。 +""" + +from datetime import date + +from sqlalchemy import select, update +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from ..models import AgentDailyUsage + + +class AgentRateLimitRepository: + """ユーザ×日の Agent リクエスト回数カウンタ。""" + + def __init__(self, db: Session, user_id: str): + self.db = db + self.user_id = user_id + + def increment_and_get(self, usage_date: date) -> int: + """指定日のカウントを 1 増やし、増加後の値を返す(commit はしない)。 + + 行が無ければ 1 で作成し、あれば ``request_count = request_count + 1`` の + 原子的 UPDATE で増やす。INSERT 競合時は UPDATE 経路にフォールバックする。 + """ + existing = self.db.execute( + select(AgentDailyUsage).where( + AgentDailyUsage.user_id == self.user_id, + AgentDailyUsage.usage_date == usage_date, + ) + ).scalar_one_or_none() + + if existing is None: + row = AgentDailyUsage( + user_id=self.user_id, usage_date=usage_date, request_count=1 + ) + self.db.add(row) + try: + self.db.flush() + return 1 + except IntegrityError: + # 別リクエストが同一キーを先に作成した場合は UPDATE 経路へ + self.db.rollback() + + self.db.execute( + update(AgentDailyUsage) + .where( + AgentDailyUsage.user_id == self.user_id, + AgentDailyUsage.usage_date == usage_date, + ) + .values(request_count=AgentDailyUsage.request_count + 1) + ) + self.db.flush() + count = self.db.execute( + select(AgentDailyUsage.request_count).where( + AgentDailyUsage.user_id == self.user_id, + AgentDailyUsage.usage_date == usage_date, + ) + ).scalar_one_or_none() + if count is None: + # 直前に増やした行が消えるのは想定外。握りつぶさず明示的に失敗させる。 + raise RuntimeError( + f"agent_daily_usage の再取得に失敗: user_id={self.user_id} date={usage_date}" + ) + return count diff --git a/backend/app/routers/agent.py b/backend/app/routers/agent.py index 8f247391..d121dba8 100644 --- a/backend/app/routers/agent.py +++ b/backend/app/routers/agent.py @@ -28,6 +28,7 @@ ) from ..services.agent.context_builder import build_reference_context from ..services.agent.llm.base import LLMError +from ..services.agent.rate_limit import AgentRateLimitExceededError, enforce_daily_limit from ..services.agent.resume_draft.context import ( ResumeDraftNoRepositoriesError, ResumeDraftSourceUnavailableError, @@ -44,6 +45,24 @@ router = APIRouter(prefix="/api/agent", tags=["agent"]) +def _enforce_agent_daily_limit(db: Session, user_id: str) -> None: + """Agent の日次利用上限を確認する(#521 / ADR-0023)。 + + プリペイド課金(残高)に代わる abuse 防止。今日(JST)のカウントを原子的に増やし、 + 上限超過なら 429 を返す。拒否時もカウントは確定させ、連続試行を含めて数える。 + """ + try: + enforce_daily_limit(db, user_id) + db.commit() + except AgentRateLimitExceededError: + db.commit() + raise_app_error( + status_code=429, + code=ErrorCode.AGENT_DAILY_LIMIT_EXCEEDED, + message=get_error("agent.daily_limit_exceeded"), + ) + + def _record_usage_after_llm( db: Session, user_id: str, usage: AgentUsage, *, description: str | None = None ) -> None: @@ -66,6 +85,8 @@ async def agent_chat( (クレジット消費・使用ログの記録は除く / ADR-0012)。 ユーザーが確認して「適用」した時点で既存の保存 API が呼ばれる。 """ + # 日次利用上限(abuse 防止 / #521・ADR-0023)を LLM 呼び出し前に確認する + _enforce_agent_daily_limit(db, user.id) # 有料モデル(sonnet)は LLM を呼ぶ前に残高をチェックする。実コストは応答後に # 確定するため事後減算とし、チェック通過後の負残高は許容する(ADR-0012) try: @@ -136,6 +157,8 @@ async def start_resume_draft( 確定した職務経歴書(``resumes``)とは別物で、そちらには書き込まない。 課金は生成タスク側で確定する(残高の事前チェックのみ本エンドポイントで行う / ADR-0012)。 """ + # 日次利用上限(abuse 防止 / #521・ADR-0023)を生成開始前に確認する + _enforce_agent_daily_limit(db, user.id) # 有料モデルは生成を開始する前に残高をチェックする(チャットと同一契約 / ADR-0012) try: credit_service.ensure_can_use_model(db, user.id, body.model) diff --git a/backend/app/services/agent/rate_limit.py b/backend/app/services/agent/rate_limit.py new file mode 100644 index 00000000..b5087233 --- /dev/null +++ b/backend/app/services/agent/rate_limit.py @@ -0,0 +1,60 @@ +"""Agent エンドポイントのユーザ単位日次レート制限(#521 / ADR-0023)。 + +プリペイド課金(残高チェック)に代わる abuse 防止。ユーザ×日ごとのリクエスト回数を +原子的に増やし、`AGENT_DAILY_LIMIT` を超えたら `AgentRateLimitExceededError` を raise する。 +日次リセットは JST 基準。 +""" + +import os +from datetime import date, datetime, timezone + +from sqlalchemy.orm import Session + +from ...core import env_keys +from ...core.date_utils import JST +from ...repositories.agent_rate_limit import AgentRateLimitRepository + +# 未設定時の既定日次上限。Haiku は安価($1/$5 per 1M tokens)なので緩めに設定する。 +DEFAULT_AGENT_DAILY_LIMIT = 50 + + +class AgentRateLimitExceededError(Exception): + """日次リクエスト上限を超過した。""" + + def __init__(self, limit: int): + self.limit = limit + super().__init__(f"Agent の 1 日あたりの利用上限({limit} 回)に達しました") + + +def resolve_daily_limit() -> int: + """`AGENT_DAILY_LIMIT` から日次上限を解決する。未設定・不正値は既定値を使う。""" + raw = os.getenv(env_keys.AGENT_DAILY_LIMIT) + if raw is None or not raw.strip(): + return DEFAULT_AGENT_DAILY_LIMIT + try: + value = int(raw) + except ValueError: + return DEFAULT_AGENT_DAILY_LIMIT + return value if value > 0 else DEFAULT_AGENT_DAILY_LIMIT + + +def _today_jst() -> date: + """現在の JST 日付を返す(日次リセットの境界)。""" + return datetime.now(timezone.utc).astimezone(JST).date() + + +def enforce_daily_limit(db: Session, user_id: str, limit: int | None = None) -> int: + """今日(JST)のリクエスト回数を原子的に増やし、上限超過なら raise する。 + + `limit` 未指定時は `resolve_daily_limit()` で env から解決する。 + 増加後のカウントが `limit` を超えた場合に `AgentRateLimitExceededError` を raise する + (limit=N なら N 回目までは許可、N+1 回目で拒否)。commit は呼び出し側の責務。 + """ + if limit is None: + limit = resolve_daily_limit() + + today = _today_jst() + count = AgentRateLimitRepository(db, user_id).increment_and_get(today) + if count > limit: + raise AgentRateLimitExceededError(limit) + return count diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 85fba8ba..023ac98b 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -74,6 +74,7 @@ testpaths = ["tests"] [tool.mutmut] source_paths = ["app"] only_mutate = [ + "app/services/agent/rate_limit.py", # Agent 日次レート制限判定(#521) "app/services/billing/pricing.py", # 課金レート表 "app/services/billing/credit_service.py", # クレジット計算・残高 "app/services/intelligence/skills/*", # スキル推論(aggregator/linguist/パーサ群) diff --git a/backend/tests/test_agent.py b/backend/tests/test_agent.py index 5d229033..b14ac3d6 100644 --- a/backend/tests/test_agent.py +++ b/backend/tests/test_agent.py @@ -144,6 +144,27 @@ def _llm_json(field: str, value: str, message: str = "改善案です。") -> st ) +def test_chat_daily_rate_limit_returns_429(client: TestClient, monkeypatch) -> None: + """日次上限(#521): 上限到達後の /agent/chat は 429 + AGENT_DAILY_LIMIT_EXCEEDED を返す。""" + from app.core import env_keys + + monkeypatch.setenv(env_keys.AGENT_DAILY_LIMIT, "1") + _mock_llm(monkeypatch, response=_llm_json("career_summary", "改善された職務要約。")) + headers = auth_header(client, "rate-limited-user") + payload = { + "scope": "career_summary", + "prompt": "職務要約を改善してください", + "resume": _resume_payload(), + } + + first = client.post("/api/agent/chat", json=payload, headers=headers) + assert first.status_code == 200 + + second = client.post("/api/agent/chat", json=payload, headers=headers) + assert second.status_code == 429 + assert second.json()["code"] == "AGENT_DAILY_LIMIT_EXCEEDED" + + def test_chat_career_summary_success(client: TestClient, monkeypatch) -> None: """正常系: career_summary スコープで operations が返る。""" _mock_llm(monkeypatch, response=_llm_json("career_summary", "改善された職務要約。")) diff --git a/backend/tests/test_agent_rate_limit.py b/backend/tests/test_agent_rate_limit.py new file mode 100644 index 00000000..52c20939 --- /dev/null +++ b/backend/tests/test_agent_rate_limit.py @@ -0,0 +1,111 @@ +"""Agent 日次レート制限の単体テスト(#521 / ADR-0023)。 + +決定論ロジック(上限判定・日次リセット)を実 SQLite で検証する(DB モックなし)。 +""" + +from datetime import date, datetime, timezone + +import pytest +from app.core.date_utils import JST +from app.models import AgentDailyUsage, User +from app.repositories import UserRepository +from app.services.agent.rate_limit import ( + AgentRateLimitExceededError, + enforce_daily_limit, +) +from sqlalchemy import select + + +def _make_user(db_session, username: str) -> User: + repo = UserRepository(db_session) + if not repo.get_by_username(username): + repo.create(username, email=f"{username}@example.com") + db_session.commit() + user = repo.get_by_username(username) + assert user is not None + return user + + +def _count_row(db_session, user_id: str, usage_date: date) -> int | None: + return db_session.execute( + select(AgentDailyUsage.request_count).where( + AgentDailyUsage.user_id == user_id, + AgentDailyUsage.usage_date == usage_date, + ) + ).scalar_one_or_none() + + +def test_under_limit_is_allowed_and_increments(db_session) -> None: + """上限未満のリクエストは許可され、カウントが 1 増える。""" + user = _make_user(db_session, "rl-under") + + count = enforce_daily_limit(db_session, user.id, limit=50) + + assert count == 1 + # DB にカウンタ行が作られ 1 になっている + today = datetime.now(timezone.utc).astimezone(JST).date() + assert _count_row(db_session, user.id, today) == 1 + + +def test_up_to_limit_is_allowed_boundary(db_session) -> None: + """境界: 上限 N 回目までは許可される(off-by-one 検証)。""" + user = _make_user(db_session, "rl-boundary") + + counts = [enforce_daily_limit(db_session, user.id, limit=3) for _ in range(3)] + + assert counts == [1, 2, 3] # 3 回目(= limit)までは raise しない + + +def test_over_limit_raises(db_session) -> None: + """上限超過(N+1 回目)で AgentRateLimitExceededError を raise する。""" + user = _make_user(db_session, "rl-over") + for _ in range(2): + enforce_daily_limit(db_session, user.id, limit=2) + + with pytest.raises(AgentRateLimitExceededError): + enforce_daily_limit(db_session, user.id, limit=2) + + +def test_counts_are_per_date(db_session, monkeypatch) -> None: + """日付(JST)が変わるとカウントがリセットされ、翌日は独立してカウントされる。""" + import app.services.agent.rate_limit as rl + + user = _make_user(db_session, "rl-date") + + fixed = {"today": date(2026, 7, 21)} + monkeypatch.setattr(rl, "_today_jst", lambda: fixed["today"]) + + assert enforce_daily_limit(db_session, user.id, limit=5) == 1 + assert enforce_daily_limit(db_session, user.id, limit=5) == 2 + # 翌日へ進めるとカウントが 1 に戻る + fixed["today"] = date(2026, 7, 22) + assert enforce_daily_limit(db_session, user.id, limit=5) == 1 + + +def test_counts_are_per_user(db_session) -> None: + """ユーザごとに独立(ユーザ A の消費がユーザ B の上限に影響しない)。""" + user_a = _make_user(db_session, "rl-user-a") + user_b = _make_user(db_session, "rl-user-b") + for _ in range(3): + enforce_daily_limit(db_session, user_a.id, limit=3) + + # B は A の消費に影響されず、1 回目から許可される + assert enforce_daily_limit(db_session, user_b.id, limit=3) == 1 + + +def test_limit_resolved_from_env(monkeypatch) -> None: + """上限値は AGENT_DAILY_LIMIT(未設定時はデフォルト)から解決される。""" + from app.core import env_keys + from app.services.agent.rate_limit import DEFAULT_AGENT_DAILY_LIMIT, resolve_daily_limit + + monkeypatch.delenv(env_keys.AGENT_DAILY_LIMIT, raising=False) + assert resolve_daily_limit() == DEFAULT_AGENT_DAILY_LIMIT + + monkeypatch.setenv(env_keys.AGENT_DAILY_LIMIT, "7") + assert resolve_daily_limit() == 7 + + # 不正値・非正値は既定値にフォールバック + monkeypatch.setenv(env_keys.AGENT_DAILY_LIMIT, "abc") + assert resolve_daily_limit() == DEFAULT_AGENT_DAILY_LIMIT + monkeypatch.setenv(env_keys.AGENT_DAILY_LIMIT, "0") + assert resolve_daily_limit() == DEFAULT_AGENT_DAILY_LIMIT diff --git a/docker-compose.yml b/docker-compose.yml index 041f9025..8ca5d0c4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -50,6 +50,8 @@ services: OLLAMA_BASE_URL: ${OLLAMA_BASE_URL} OLLAMA_MODEL: ${OLLAMA_MODEL} OLLAMA_TIMEOUT_SECONDS: ${OLLAMA_TIMEOUT_SECONDS} + # Agent の日次利用上限(#521 / ADR-0023)。未設定なら backend 既定値(50) + AGENT_DAILY_LIMIT: ${AGENT_DAILY_LIMIT} # 決済(Stripe Checkout / ADR-0012 Phase 2)。未設定なら購入機能は無効 STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY} STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET} diff --git a/docs/api.md b/docs/api.md index ae40e494..bc167fe8 100644 --- a/docs/api.md +++ b/docs/api.md @@ -136,6 +136,7 @@ REST API エンドポイント一覧と、バックエンド/フロントエ | `OLLAMA_BASE_URL` | ローカル Ollama のベース URL(既定: `http://localhost:11434`) | | `OLLAMA_MODEL` | ローカル Ollama のモデル名(既定: `llama3.2`) | | `OLLAMA_TIMEOUT_SECONDS` | ローカル Ollama 呼び出しの HTTP タイムアウト秒数(既定: `300`) | +| `AGENT_DAILY_LIMIT` | Agent エンドポイント(`/agent/chat`・`/agent/resume-draft/run`)のユーザ単位日次リクエスト上限(#521・ADR-0023。未設定時の既定: `50`)。本番は未設定=既定値で運用 | ### 決済(Stripe Checkout / ADR-0012 Phase 2) diff --git a/web/src/constants/errorCodes.ts b/web/src/constants/errorCodes.ts index 7dff9391..afd32ea5 100644 --- a/web/src/constants/errorCodes.ts +++ b/web/src/constants/errorCodes.ts @@ -30,6 +30,8 @@ export const ERROR_CODES = [ // Agent(LLM チャット / ADR-0010) "AGENT_LLM_ERROR", "AGENT_PARSE_ERROR", + // Agent の日次利用上限(#521 / ADR-0023) + "AGENT_DAILY_LIMIT_EXCEEDED", // 課金(プリペイドクレジット / ADR-0012) "INSUFFICIENT_CREDITS", // 決済(Stripe Checkout / ADR-0012 Phase 2) diff --git a/web/src/constants/errorMessages.ts b/web/src/constants/errorMessages.ts index e3ee5fe6..db904d87 100644 --- a/web/src/constants/errorMessages.ts +++ b/web/src/constants/errorMessages.ts @@ -47,6 +47,10 @@ export const ERROR_CONFIG: Record< message: "AI の応答を解釈できませんでした", recovery: { label: "もう一度試す", fn: null }, }, + AGENT_DAILY_LIMIT_EXCEEDED: { + message: "本日の AI 利用回数の上限に達しました", + recovery: null, + }, INSUFFICIENT_CREDITS: { // fn は null(自動切替はしない)。ユーザー自身がモデル選択で切り替える手動アクション message: "クレジット残高が不足しています",