diff --git a/.env.example b/.env.example index 6c953ec..39f4446 100644 --- a/.env.example +++ b/.env.example @@ -5,6 +5,7 @@ # 运行时配置 ############ ERYAO_RUNTIME__ENVIRONMENT=dev +ERYAO_RUNTIME__DEBUG=true ERYAO_RUNTIME__LOG_LEVEL=INFO ERYAO_RUNTIME__SQL_LOG_QUERIES=false ERYAO_RUNTIME__TRUSTED_PROXY_IPS='["127.0.0.1", "172.18.0.1"]' @@ -105,9 +106,21 @@ ERYAO_TEST__CODE=123456 # Apple IAP 配置 ############ ERYAO_APPLE_IAP__BUNDLE_ID=com.meeyao.qianwen -# Apple IAP 环境识别。auto 表示以后端验签后的 Apple transaction environment 为准。 -ERYAO_APPLE_IAP__ENVIRONMENT=auto # Server API 密钥(可选,用于主动查询交易状态) ERYAO_APPLE_IAP__SERVER_API_KEY_ID= ERYAO_APPLE_IAP__SERVER_API_PRIVATE_KEY= ERYAO_APPLE_IAP__SERVER_API_ISSUER_ID= +# 沙盒测试账号(仅用于手动测试,不用于后端验证) +ERYAO_APPLE_IAP__SANDBOX_TESTER_EMAIL= +ERYAO_APPLE_IAP__SANDBOX_TESTER_PASSWORD= +# Server Notifications V2 URL(在 App Store Connect 中配置) +# 格式: https:///api/v1/payments/apple/notifications +ERYAO_APPLE_IAP__SERVER_NOTIFICATIONS_URL= + +############ +# CREEM Payment 配置 +############ +ERYAO_CREEM__API_KEY= +ERYAO_CREEM__WEBHOOK_SECRET= +ERYAO_CREEM__BASE_URL=https://test-api.creem.io +ERYAO_CREEM__SUCCESS_URL=https://yourdomain.com/store?payment=success diff --git a/backend/alembic/versions/20260511_0001_creem_transactions.py b/backend/alembic/versions/20260511_0001_creem_transactions.py new file mode 100644 index 0000000..cd08344 --- /dev/null +++ b/backend/alembic/versions/20260511_0001_creem_transactions.py @@ -0,0 +1,68 @@ +"""Create creem_transactions table for CREEM payment integration. + +Revision ID: 20260511_0001 +Revises: 20260428_0004 +Create Date: 2026-05-11 00:01:00 +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +revision: str = "20260511_0001" +down_revision: Union[str, Sequence[str], None] = "20260428_0004" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "creem_transactions", + sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("user_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("product_code", sa.String(length=32), nullable=False), + sa.Column("creem_product_id", sa.String(length=128), nullable=False), + sa.Column("checkout_id", sa.String(length=128), nullable=False), + sa.Column("order_id", sa.String(length=128), nullable=True), + sa.Column("customer_id", sa.String(length=128), nullable=True), + sa.Column("status", sa.String(length=24), nullable=False), + sa.Column("credits", sa.BigInteger(), nullable=False), + sa.Column("amount_cents", sa.BigInteger(), nullable=False), + sa.Column("currency", sa.String(length=8), nullable=False), + sa.Column("creem_payload", postgresql.JSONB(astext_type=sa.Text()), server_default=sa.text("'{}'::jsonb"), nullable=False), + sa.Column("ledger_event_id", sa.String(length=128), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.CheckConstraint("status in ('pending', 'completed', 'failed', 'refunded')", name="ck_creem_transactions_status"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("checkout_id", name="uq_creem_transactions_checkout_id"), + ) + op.create_index("ix_creem_transactions_user_created_at", "creem_transactions", ["user_id", sa.text("created_at DESC")]) + op.create_index("ix_creem_transactions_status_updated_at", "creem_transactions", ["status", sa.text("updated_at DESC")]) + _enable_service_only_rls("creem_transactions") + + +def downgrade() -> None: + _drop_service_only_rls("creem_transactions") + op.drop_table("creem_transactions") + + +def _enable_service_only_rls(table_name: str) -> None: + for role in ["anon", "authenticated"]: + for action in ["select", "insert", "update", "delete"]: + op.execute(f"DROP POLICY IF EXISTS {role}_{action}_{table_name} ON {table_name}") + op.execute(f"ALTER TABLE {table_name} ENABLE ROW LEVEL SECURITY") + for role in ["anon", "authenticated"]: + op.execute(f"CREATE POLICY {role}_select_{table_name} ON {table_name} FOR SELECT TO {role} USING (false)") + op.execute(f"CREATE POLICY {role}_insert_{table_name} ON {table_name} FOR INSERT TO {role} WITH CHECK (false)") + op.execute(f"CREATE POLICY {role}_update_{table_name} ON {table_name} FOR UPDATE TO {role} USING (false) WITH CHECK (false)") + op.execute(f"CREATE POLICY {role}_delete_{table_name} ON {table_name} FOR DELETE TO {role} USING (false)") + + +def _drop_service_only_rls(table_name: str) -> None: + for role in ["anon", "authenticated"]: + for action in ["select", "insert", "update", "delete"]: + op.execute(f"DROP POLICY IF EXISTS {role}_{action}_{table_name} ON {table_name}") + op.execute(f"ALTER TABLE {table_name} DISABLE ROW LEVEL SECURITY") diff --git a/backend/src/core/config/settings.py b/backend/src/core/config/settings.py index 0566acb..6d7c2c4 100644 --- a/backend/src/core/config/settings.py +++ b/backend/src/core/config/settings.py @@ -232,6 +232,13 @@ class AppleIapSettings(BaseModel): server_api_private_key: SecretStr | None = None +class CreemSettings(BaseModel): + api_key: SecretStr | None = None + webhook_secret: SecretStr | None = None + base_url: str = "https://test-api.creem.io" + success_url: str = "" + + def _resolve_env_files() -> list[str]: """Resolve env files in order: .env.local overrides .env""" current = Path(__file__).resolve() @@ -280,6 +287,7 @@ class Settings(BaseSettings): agent_runtime: AgentRuntimeSettings = Field(default_factory=AgentRuntimeSettings) points_policy: PointsPolicySettings = Field(default_factory=PointsPolicySettings) apple_iap: AppleIapSettings = Field(default_factory=AppleIapSettings) + creem: CreemSettings = Field(default_factory=CreemSettings) feedback_report: FeedbackReportSettings = Field( default_factory=FeedbackReportSettings ) diff --git a/backend/src/core/config/static/packages/mapping.yaml b/backend/src/core/config/static/packages/mapping.yaml index 8faa7f6..80dadfc 100644 --- a/backend/src/core/config/static/packages/mapping.yaml +++ b/backend/src/core/config/static/packages/mapping.yaml @@ -1,24 +1,28 @@ product_mappings: new_user_pack: app_store_product_id: com.meeyao.qianwen.new_user_pack + creem_product_id: prod_2x9LzVlR3ot1HLgbIZALPd credits: 60 type: starter sort_order: 0 enabled: true starter_pack: app_store_product_id: com.meeyao.qianwen.starter_pack + creem_product_id: prod_697ay0pXFXrBYEVC7HS0MR credits: 100 type: regular sort_order: 10 enabled: true popular_pack: app_store_product_id: com.meeyao.qianwen.popular_pack + creem_product_id: prod_5ivxlPnZWN6dIhnOxctThy credits: 210 type: regular sort_order: 20 enabled: true premium_pack: app_store_product_id: com.meeyao.qianwen.premium_pack + creem_product_id: prod_2L13k70jlpPYkdHhexHP2s credits: 415 type: regular sort_order: 30 diff --git a/backend/src/core/db/session.py b/backend/src/core/db/session.py index 0b4a9cf..b3ac918 100644 --- a/backend/src/core/db/session.py +++ b/backend/src/core/db/session.py @@ -14,6 +14,9 @@ engine: AsyncEngine = create_async_engine( config.database_url, echo=config.runtime.sql_log_queries, pool_pre_ping=True, + pool_size=3, + max_overflow=0, + pool_timeout=10, ) AsyncSessionLocal: async_sessionmaker[AsyncSession] = async_sessionmaker( diff --git a/backend/src/models/creem_transaction.py b/backend/src/models/creem_transaction.py new file mode 100644 index 0000000..de551be --- /dev/null +++ b/backend/src/models/creem_transaction.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +import uuid +from enum import Enum + +from sqlalchemy import ( + BigInteger, + CheckConstraint, + Index, + String, + UniqueConstraint, + text, +) +from sqlalchemy.dialects.postgresql import JSONB, UUID +from sqlalchemy.orm import Mapped, mapped_column + +from core.db.base import Base, TimestampMixin + + +class CreemTransactionStatus(str, Enum): + PENDING = "pending" + COMPLETED = "completed" + FAILED = "failed" + REFUNDED = "refunded" + + +class CreemTransaction(TimestampMixin, Base): + __tablename__ = "creem_transactions" + __table_args__ = ( + CheckConstraint( + "status in ('pending', 'completed', 'failed', 'refunded')", + name="ck_creem_transactions_status", + ), + UniqueConstraint( + "checkout_id", name="uq_creem_transactions_checkout_id" + ), + Index( + "ix_creem_transactions_user_created_at", + "user_id", + text("created_at DESC"), + ), + Index( + "ix_creem_transactions_status_updated_at", + "status", + text("updated_at DESC"), + ), + ) + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + primary_key=True, + default=uuid.uuid4, + ) + user_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + nullable=False, + ) + product_code: Mapped[str] = mapped_column(String(32), nullable=False) + creem_product_id: Mapped[str] = mapped_column(String(128), nullable=False) + checkout_id: Mapped[str] = mapped_column(String(128), nullable=False) + order_id: Mapped[str | None] = mapped_column(String(128), nullable=True) + customer_id: Mapped[str | None] = mapped_column(String(128), nullable=True) + status: Mapped[str] = mapped_column(String(24), nullable=False) + credits: Mapped[int] = mapped_column(BigInteger, nullable=False) + amount_cents: Mapped[int] = mapped_column(BigInteger, nullable=False) + currency: Mapped[str] = mapped_column(String(8), nullable=False) + creem_payload: Mapped[dict[str, object]] = mapped_column( + "creem_payload", + JSONB(), + nullable=False, + server_default=text("'{}'::jsonb"), + default=dict, + ) + ledger_event_id: Mapped[str | None] = mapped_column(String(128), nullable=True) diff --git a/backend/src/v1/payments/creem_client.py b/backend/src/v1/payments/creem_client.py new file mode 100644 index 0000000..5cdf235 --- /dev/null +++ b/backend/src/v1/payments/creem_client.py @@ -0,0 +1,135 @@ +from __future__ import annotations + +import hashlib +import hmac +import logging +from dataclasses import dataclass +from typing import Any + +import httpx + +from core.config.settings import config + +logger = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class CreemProduct: + product_id: str + name: str + price_cents: int + currency: str + + +@dataclass(frozen=True) +class CreemCheckout: + checkout_id: str + checkout_url: str + + +class CreemClient: + def __init__(self) -> None: + settings = config.creem + self._api_key = settings.api_key.get_secret_value() if settings.api_key else None + self._base_url = settings.base_url.rstrip("/") + self._timeout = httpx.Timeout(30.0, connect=5.0) + + def _headers(self) -> dict[str, str]: + if not self._api_key: + raise RuntimeError("CREEM API key not configured") + return { + "x-api-key": self._api_key, + "Content-Type": "application/json", + } + + async def get_products(self) -> list[CreemProduct]: + """Fetch all products from CREEM.""" + async with httpx.AsyncClient(timeout=self._timeout) as client: + resp = await client.get( + f"{self._base_url}/v1/products/search", + headers=self._headers(), + ) + resp.raise_for_status() + data: Any = resp.json() + + products: list[CreemProduct] = [] + for item in data.get("items", []): + product_id = item.get("id", "") + name = item.get("name", "") + price = item.get("price", 0) + currency = item.get("currency", "USD") + products.append( + CreemProduct( + product_id=product_id, + name=name, + price_cents=int(price), + currency=currency, + ) + ) + return products + + async def get_product(self, product_id: str) -> CreemProduct | None: + """Fetch a single product by ID.""" + async with httpx.AsyncClient(timeout=self._timeout) as client: + resp = await client.get( + f"{self._base_url}/v1/products", + params={"product_id": product_id}, + headers=self._headers(), + ) + if resp.status_code == 404: + return None + resp.raise_for_status() + data: Any = resp.json() + + return CreemProduct( + product_id=data.get("id", ""), + name=data.get("name", ""), + price_cents=int(data.get("price", 0)), + currency=data.get("currency", "USD"), + ) + + async def create_checkout( + self, + *, + product_id: str, + success_url: str, + customer_email: str | None = None, + metadata: dict[str, Any] | None = None, + ) -> CreemCheckout: + """Create a checkout session.""" + payload: dict[str, Any] = { + "product_id": product_id, + "success_url": success_url, + } + if customer_email: + payload["customer"] = {"email": customer_email} + if metadata: + payload["metadata"] = metadata + + async with httpx.AsyncClient(timeout=self._timeout) as client: + resp = await client.post( + f"{self._base_url}/v1/checkouts", + headers=self._headers(), + json=payload, + ) + resp.raise_for_status() + data: Any = resp.json() + + return CreemCheckout( + checkout_id=data.get("id", ""), + checkout_url=data.get("checkout_url", ""), + ) + + @staticmethod + def verify_webhook_signature( + payload: bytes, + signature: str, + secret: str, + ) -> bool: + """Verify webhook signature using HMAC-SHA256.""" + expected = hmac.new( + secret.encode("utf-8"), + payload, + hashlib.sha256, + ).hexdigest() + return hmac.compare_digest(expected, signature) diff --git a/backend/src/v1/payments/creem_service.py b/backend/src/v1/payments/creem_service.py new file mode 100644 index 0000000..ce9be9e --- /dev/null +++ b/backend/src/v1/payments/creem_service.py @@ -0,0 +1,371 @@ +from __future__ import annotations + +import hashlib +import hmac +import json +import logging +from dataclasses import dataclass +from pathlib import Path +from typing import Any +from uuid import UUID, uuid4 + +import yaml + +from core.config.settings import config +from core.http.errors import ApiProblemError, problem_payload +from models.creem_transaction import CreemTransaction +from schemas.domain.points import ( + ApplyPointsChangeCommand, + PurchaseLedgerMetadata, +) +from schemas.enums import PointsBizType, PointsChangeType, PointsOperatorType +from v1.payments.creem_client import CreemClient, CreemProduct +from v1.payments.repository import PaymentRepository +from v1.points.repository import PointsRepository + +logger = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class CreemProductMapping: + creem_product_id: str + credits: int + type: str + sort_order: int = 0 + enabled: bool = True + + +_creem_product_mappings_cache: dict[str, CreemProductMapping] | None = None + + +def _load_creem_product_mappings() -> dict[str, CreemProductMapping]: + global _creem_product_mappings_cache + if _creem_product_mappings_cache is not None: + return _creem_product_mappings_cache + + mapping_path = ( + Path(__file__).parent.parent.parent + / "core/config/static/packages/mapping.yaml" + ) + with mapping_path.open("r", encoding="utf-8") as f: + raw: Any = yaml.safe_load(f) or {} + + mappings: dict[str, CreemProductMapping] = {} + product_mappings: Any = raw.get("product_mappings", {}) + for code, entry in product_mappings.items(): + if entry.get("creem_product_id"): + mappings[str(code)] = CreemProductMapping( + creem_product_id=str(entry["creem_product_id"]), + credits=int(entry["credits"]), + type=str(entry["type"]), + sort_order=int(entry.get("sort_order", 0)), + enabled=bool(entry.get("enabled", True)), + ) + + _creem_product_mappings_cache = mappings + return mappings + + +def clear_creem_product_mappings_cache() -> None: + global _creem_product_mappings_cache + _creem_product_mappings_cache = None + + +@dataclass(frozen=True) +class PackageWithPrice: + product_code: str + creem_product_id: str + credits: int + type: str + sort_order: int + price_cents: int + currency: str + + +@dataclass(frozen=True) +class CreateCheckoutResult: + checkout_id: str + checkout_url: str + + +class CreemService: + def __init__( + self, + *, + payment_repo: PaymentRepository, + points_repo: PointsRepository, + client: CreemClient, + ) -> None: + self._payment_repo: PaymentRepository = payment_repo + self._points_repo: PointsRepository = points_repo + self._client: CreemClient = client + + async def get_packages_with_prices(self) -> list[PackageWithPrice]: + """Get all packages with dynamic prices from CREEM API.""" + mappings = _load_creem_product_mappings() + products = await self._client.get_products() + + product_by_id: dict[str, CreemProduct] = {p.product_id: p for p in products} + + result: list[PackageWithPrice] = [] + for code, mapping in mappings.items(): + if not mapping.enabled: + continue + product = product_by_id.get(mapping.creem_product_id) + if product is None: + logger.warning( + "CREEM product not found: code=%s product_id=%s", + code, + mapping.creem_product_id, + ) + continue + result.append( + PackageWithPrice( + product_code=code, + creem_product_id=mapping.creem_product_id, + credits=mapping.credits, + type=mapping.type, + sort_order=mapping.sort_order, + price_cents=product.price_cents, + currency=product.currency, + ) + ) + + result.sort(key=lambda p: p.sort_order) + return result + + async def create_checkout( + self, + *, + user_id: UUID, + user_email: str, + product_code: str, + ) -> CreateCheckoutResult: + """Create a CREEM checkout session.""" + mappings = _load_creem_product_mappings() + mapping = mappings.get(product_code) + if mapping is None: + raise ApiProblemError( + status_code=404, + detail=problem_payload( + code="PAYMENT_PRODUCT_NOT_FOUND", + detail=f"Product not found: {product_code}", + ), + ) + + is_starter = mapping.type == "starter" + normalized_email = user_email.strip().lower() + email_hash = ( + self._build_email_hash(normalized_email) if normalized_email else None + ) + + if is_starter: + if not email_hash: + raise ApiProblemError( + status_code=422, + detail=problem_payload( + code="PAYMENT_STARTER_PACK_INELIGIBLE", + detail="Email required for starter pack purchase", + ), + ) + claim = await self._payment_repo.get_register_bonus_claim( + email_hash=email_hash + ) + if claim is not None and claim.has_purchased_starter_pack: + raise ApiProblemError( + status_code=409, + detail=problem_payload( + code="PAYMENT_STARTER_PACK_INELIGIBLE", + detail="Starter pack already purchased for this email", + ), + ) + + product = await self._client.get_product(mapping.creem_product_id) + if product is None: + raise ApiProblemError( + status_code=404, + detail=problem_payload( + code="PAYMENT_PRODUCT_NOT_FOUND", + detail=f"CREEM product not found: {mapping.creem_product_id}", + ), + ) + + success_url = config.creem.success_url + checkout = await self._client.create_checkout( + product_id=mapping.creem_product_id, + success_url=success_url, + customer_email=normalized_email or None, + metadata={ + "user_id": str(user_id), + "product_code": product_code, + }, + ) + + transaction = CreemTransaction( + id=uuid4(), + user_id=user_id, + product_code=product_code, + creem_product_id=mapping.creem_product_id, + checkout_id=checkout.checkout_id, + status="pending", + credits=mapping.credits, + amount_cents=product.price_cents, + currency=product.currency, + creem_payload={"checkout_url": checkout.checkout_url}, + ) + await self._payment_repo.insert_creem_transaction(transaction=transaction) + await self._payment_repo.commit() + + logger.info( + "CREEM checkout created: user_id=%s product_code=%s checkout_id=%s", + user_id, + product_code, + checkout.checkout_id, + ) + + return CreateCheckoutResult( + checkout_id=checkout.checkout_id, + checkout_url=checkout.checkout_url, + ) + + async def handle_webhook( + self, + *, + payload: bytes, + signature: str, + ) -> None: + """Handle CREEM webhook notification.""" + settings = config.creem + secret = settings.webhook_secret + if secret is None: + logger.error("CREEM webhook_secret not configured") + return + + secret_value = secret.get_secret_value() + if not CreemClient.verify_webhook_signature(payload, signature, secret_value): + logger.warning("CREEM webhook signature verification failed") + return + + try: + event: Any = json.loads(payload) + except json.JSONDecodeError: + logger.warning("CREEM webhook payload is not valid JSON") + return + + event_type = event.get("eventType", "") + obj = event.get("object", {}) + + if event_type == "checkout.completed": + await self._handle_checkout_completed(obj) + + async def _handle_checkout_completed(self, obj: dict[str, Any]) -> None: + # CREEM webhook structure: checkout_id is in "id", order_id in "order.id", customer_id in "customer.id" + checkout_id = obj.get("id", "") + order_obj = obj.get("order", {}) + order_id = order_obj.get("id") if isinstance(order_obj, dict) else None + customer_obj = obj.get("customer", {}) + customer_id = customer_obj.get("id") if isinstance(customer_obj, dict) else None + metadata = obj.get("metadata", {}) + + txn = await self._payment_repo.get_creem_transaction_by_checkout_id( + checkout_id=checkout_id + ) + if txn is None: + logger.warning( + "CREEM checkout.completed for unknown checkout_id: %s", + checkout_id, + ) + return + + if txn.status == "completed": + logger.info( + "CREEM checkout already completed: checkout_id=%s", + checkout_id, + ) + return + + user_id = txn.user_id + credits = txn.credits + + account = await self._payment_repo.get_or_create_user_points_for_update( + user_id=user_id + ) + balance = int(account.balance) + new_balance = balance + credits + + account.balance = new_balance + account.lifetime_earned = int(account.lifetime_earned) + credits + account.version = int(account.version) + 1 + + event_id = f"payment.creem:{checkout_id}" + + metadata_obj = PurchaseLedgerMetadata( + operator_type=PointsOperatorType.SYSTEM, + run_id=event_id, + ext={ + "source": "creem", + "platform": "web", + "product_code": txn.product_code, + "transaction_id": checkout_id, + "creem_product_id": txn.creem_product_id, + "order_id": order_id or "", + "customer_id": customer_id or "", + "creem_transaction_id": str(txn.id), + }, + ) + + ledger_command = ApplyPointsChangeCommand( + user_id=user_id, + change_type=PointsChangeType.PURCHASE, + biz_type=PointsBizType.PAYMENT, + biz_id=txn.id, + event_id=event_id, + amount=credits, + direction=1, + operator_id=None, + metadata=metadata_obj, + ) + + await self._points_repo.append_ledger( + command=ledger_command, + balance_after=new_balance, + ) + + txn.order_id = order_id + txn.customer_id = customer_id + txn.status = "completed" + txn.ledger_event_id = event_id + txn.creem_payload = obj + + logger.info( + "CREEM payment completed: user_id=%s checkout_id=%s credits=%d new_balance=%d", + user_id, + checkout_id, + credits, + new_balance, + ) + + mappings = _load_creem_product_mappings() + mapping = mappings.get(txn.product_code) + if mapping and mapping.type == "starter": + user_email = obj.get("customer", {}).get("email", "") + normalized_email = user_email.strip().lower() + if normalized_email: + email_hash = self._build_email_hash(normalized_email) + _ = await self._payment_repo.upsert_register_bonus_claim_for_starter_pack( + email_hash=email_hash, + user_email_snapshot=normalized_email, + first_user_id_snapshot=user_id, + ) + + await self._payment_repo.commit() + + @staticmethod + def _build_email_hash(normalized_email: str) -> str: + key = config.points_policy.register_bonus_hmac_key.get_secret_value().strip() + digest = hmac.new( + key=key.encode("utf-8"), + msg=normalized_email.encode("utf-8"), + digestmod=hashlib.sha256, + ) + return digest.hexdigest() diff --git a/backend/src/v1/payments/dependencies.py b/backend/src/v1/payments/dependencies.py index f7b6915..92ad4ac 100644 --- a/backend/src/v1/payments/dependencies.py +++ b/backend/src/v1/payments/dependencies.py @@ -5,6 +5,8 @@ from sqlalchemy.ext.asyncio import AsyncSession from core.db import get_db from v1.payments.apple_verifier import AppleJwsVerifier +from v1.payments.creem_client import CreemClient +from v1.payments.creem_service import CreemService from v1.payments.repository import PaymentRepository from v1.payments.service import PaymentService from v1.points.repository import PointsRepository @@ -19,3 +21,14 @@ def get_payment_service(session: AsyncSession = Depends(get_db)) -> PaymentServi points_repo=points_repo, verifier=verifier, ) + + +def get_creem_service(session: AsyncSession = Depends(get_db)) -> CreemService: + payment_repo = PaymentRepository(session) + points_repo = PointsRepository(session) + client = CreemClient() + return CreemService( + payment_repo=payment_repo, + points_repo=points_repo, + client=client, + ) diff --git a/backend/src/v1/payments/repository.py b/backend/src/v1/payments/repository.py index bac5a65..56d6e78 100644 --- a/backend/src/v1/payments/repository.py +++ b/backend/src/v1/payments/repository.py @@ -7,6 +7,7 @@ from sqlalchemy.dialects.postgresql import insert from sqlalchemy.ext.asyncio import AsyncSession from models.apple_iap_transaction import AppleIapTransaction +from models.creem_transaction import CreemTransaction from models.register_bonus_claims import RegisterBonusClaims from models.user_points import UserPoints @@ -84,5 +85,17 @@ class PaymentRepository: raise RuntimeError("Failed to upsert register bonus claim") return claim + async def get_creem_transaction_by_checkout_id( + self, *, checkout_id: str + ) -> CreemTransaction | None: + stmt = select(CreemTransaction).where( + CreemTransaction.checkout_id == checkout_id + ) + return (await self._session.execute(stmt)).scalar_one_or_none() + + async def insert_creem_transaction(self, *, transaction: CreemTransaction) -> None: + self._session.add(transaction) + await self._session.flush() + async def commit(self) -> None: await self._session.commit() diff --git a/backend/src/v1/payments/router.py b/backend/src/v1/payments/router.py index f84d9fe..86aa413 100644 --- a/backend/src/v1/payments/router.py +++ b/backend/src/v1/payments/router.py @@ -3,16 +3,19 @@ from __future__ import annotations import logging from typing import Annotated -from fastapi import APIRouter, Depends, Response +from fastapi import APIRouter, Depends, Request, Response from core.auth.models import CurrentUser -from v1.payments.dependencies import get_payment_service +from v1.payments.dependencies import get_creem_service, get_payment_service from v1.payments.schemas import ( AppleServerNotificationRequest, + CreateCheckoutRequest, + CreateCheckoutResponse, VerifyTransactionRequest, VerifyTransactionResponse, ) from v1.payments.service import PaymentService +from v1.payments.creem_service import CreemService from v1.users.dependencies import get_current_user logger = logging.getLogger(__name__) @@ -43,3 +46,34 @@ async def handle_apple_server_notification( ) -> Response: await service.handle_server_notification(signed_payload=request.signed_payload) return Response(status_code=200) + + +@router.post( + "/creem/checkouts", + response_model=CreateCheckoutResponse, +) +async def create_creem_checkout( + request: CreateCheckoutRequest, + service: Annotated[CreemService, Depends(get_creem_service)], + current_user: Annotated[CurrentUser, Depends(get_current_user)], +) -> CreateCheckoutResponse: + result = await service.create_checkout( + user_id=current_user.id, + user_email=current_user.email or "", + product_code=request.product_code, + ) + return CreateCheckoutResponse( + checkoutId=result.checkout_id, + checkoutUrl=result.checkout_url, + ) + + +@router.post("/creem/webhook", status_code=200) +async def handle_creem_webhook( + http_request: Request, + service: Annotated[CreemService, Depends(get_creem_service)], +) -> Response: + signature = http_request.headers.get("creem-signature", "") + payload = await http_request.body() + await service.handle_webhook(payload=payload, signature=signature) + return Response(status_code=200) diff --git a/backend/src/v1/payments/schemas.py b/backend/src/v1/payments/schemas.py index 8411734..3db23ca 100644 --- a/backend/src/v1/payments/schemas.py +++ b/backend/src/v1/payments/schemas.py @@ -45,3 +45,16 @@ class AppleServerNotificationRequest(BaseModel): model_config = ConfigDict(extra="allow") signed_payload: str = Field(alias="signedPayload", default="") + + +class CreateCheckoutRequest(BaseModel): + model_config = ConfigDict(populate_by_name=True, extra="forbid") + + product_code: str = Field(alias="productCode", min_length=1, max_length=32) + + +class CreateCheckoutResponse(BaseModel): + model_config = ConfigDict(populate_by_name=True, extra="forbid") + + checkout_id: str = Field(alias="checkoutId") + checkout_url: str = Field(alias="checkoutUrl") diff --git a/backend/src/v1/payments/service.py b/backend/src/v1/payments/service.py index ec8a99e..856207a 100644 --- a/backend/src/v1/payments/service.py +++ b/backend/src/v1/payments/service.py @@ -33,6 +33,7 @@ logger = logging.getLogger(__name__) @dataclass(frozen=True) class ProductMapping: app_store_product_id: str + creem_product_id: str | None credits: int type: str sort_order: int = 0 @@ -58,7 +59,8 @@ def _load_product_mappings() -> dict[str, ProductMapping]: product_mappings: Any = raw.get("product_mappings", {}) for code, entry in product_mappings.items(): mappings[str(code)] = ProductMapping( - app_store_product_id=str(entry["app_store_product_id"]), + app_store_product_id=str(entry.get("app_store_product_id", "")), + creem_product_id=str(entry["creem_product_id"]) if entry.get("creem_product_id") else None, credits=int(entry["credits"]), type=str(entry["type"]), sort_order=int(entry.get("sort_order", 0)), diff --git a/backend/src/v1/points/dependencies.py b/backend/src/v1/points/dependencies.py index bb9cf7a..6fa74ac 100644 --- a/backend/src/v1/points/dependencies.py +++ b/backend/src/v1/points/dependencies.py @@ -3,10 +3,18 @@ from __future__ import annotations from fastapi import Depends from sqlalchemy.ext.asyncio import AsyncSession +from core.config.settings import config from core.db import get_db +from v1.payments.creem_client import CreemClient from v1.points.repository import PointsRepository from v1.points.service import PointsService def get_points_service(session: AsyncSession = Depends(get_db)) -> PointsService: - return PointsService(repository=PointsRepository(session)) + creem_client: CreemClient | None = None + if config.creem.api_key: + creem_client = CreemClient() + return PointsService( + repository=PointsRepository(session), + creem_client=creem_client, + ) diff --git a/backend/src/v1/points/router.py b/backend/src/v1/points/router.py index 63ce42a..4e732f1 100644 --- a/backend/src/v1/points/router.py +++ b/backend/src/v1/points/router.py @@ -67,11 +67,14 @@ async def get_available_packages( PackageInfo( productCode=pkg.product_code, appStoreProductId=pkg.app_store_product_id, + creemProductId=pkg.creem_product_id, type=pkg.type, credits=pkg.credits, isStarter=pkg.is_starter, starterEligible=pkg.starter_eligible, sortOrder=pkg.sort_order, + priceCents=pkg.price_cents, + currency=pkg.currency, ) for pkg in result.packages ], diff --git a/backend/src/v1/points/schemas.py b/backend/src/v1/points/schemas.py index 3f774ae..3b988b5 100644 --- a/backend/src/v1/points/schemas.py +++ b/backend/src/v1/points/schemas.py @@ -19,14 +19,19 @@ class PackageInfo(BaseModel): model_config = ConfigDict(populate_by_name=True, serialize_by_alias=True) product_code: str = Field(alias="productCode", min_length=1, max_length=128) - app_store_product_id: str = Field( - alias="appStoreProductId", min_length=1, max_length=256 + app_store_product_id: str | None = Field( + alias="appStoreProductId", default=None, min_length=1, max_length=256 + ) + creem_product_id: str | None = Field( + alias="creemProductId", default=None, min_length=1, max_length=256 ) type: Literal["starter", "regular"] credits: int = Field(ge=1) is_starter: bool = Field(alias="isStarter") starter_eligible: bool = Field(alias="starterEligible") sort_order: int = Field(alias="sortOrder", ge=0) + price_cents: int | None = Field(alias="priceCents", default=None, ge=0) + currency: str | None = Field(alias="currency", default=None, min_length=3, max_length=8) class PackagesResponse(BaseModel): diff --git a/backend/src/v1/points/service.py b/backend/src/v1/points/service.py index c16d1bc..6e797bf 100644 --- a/backend/src/v1/points/service.py +++ b/backend/src/v1/points/service.py @@ -20,6 +20,8 @@ from schemas.domain.points import ( from schemas.enums import PointsBizType, PointsChangeType, PointsOperatorType from schemas.domain.points import ApplyPointsChangeCommand from v1.payments.service import _load_product_mappings +from v1.payments.creem_service import _load_creem_product_mappings +from v1.payments.creem_client import CreemClient from v1.points.repository import PointsRepository from v1.points.schemas import LedgerItem @@ -65,12 +67,15 @@ class RegisterBonusResult: @dataclass(frozen=True) class PackageInfoResult: product_code: str - app_store_product_id: str + app_store_product_id: str | None + creem_product_id: str | None type: Literal["starter", "regular"] credits: int sort_order: int is_starter: bool starter_eligible: bool + price_cents: int | None = None + currency: str | None = None @dataclass(frozen=True) @@ -79,8 +84,13 @@ class PackagesResult: class PointsService: - def __init__(self, repository: PointsRepository) -> None: + def __init__( + self, + repository: PointsRepository, + creem_client: CreemClient | None = None, + ) -> None: self._repository = repository + self._creem_client = creem_client async def grant_register_bonus_if_eligible( self, @@ -453,6 +463,17 @@ class PointsService: ) product_mappings = _load_product_mappings() + creem_mappings = _load_creem_product_mappings() + + creem_prices: dict[str, tuple[int, str]] = {} + if self._creem_client: + try: + products = await self._creem_client.get_products() + creem_prices = { + p.product_id: (p.price_cents, p.currency) for p in products + } + except Exception: + pass packages: list[PackageInfoResult] = [] for product_code, mapping in product_mappings.items(): @@ -464,15 +485,25 @@ class PointsService: if pkg_type == "starter" and has_starter: continue + creem_mapping = creem_mappings.get(product_code) + creem_product_id = creem_mapping.creem_product_id if creem_mapping else None + price_cents: int | None = None + currency: str | None = None + if creem_product_id and creem_product_id in creem_prices: + price_cents, currency = creem_prices[creem_product_id] + packages.append( PackageInfoResult( product_code=product_code, app_store_product_id=mapping.app_store_product_id, + creem_product_id=creem_product_id, type=pkg_type, credits=mapping.credits, sort_order=mapping.sort_order, is_starter=pkg_type == "starter", starter_eligible=(pkg_type == "starter" and not has_starter), + price_cents=price_cents, + currency=currency, ) ) diff --git a/web/astro.config.mjs b/web/astro.config.mjs index c035bdb..b8e44d5 100644 --- a/web/astro.config.mjs +++ b/web/astro.config.mjs @@ -20,11 +20,14 @@ export default defineConfig({ vite: { plugins: [tailwindcss()], server: { + port: 4322, proxy: { '/api': { - target: 'https://api.meeyao.com', + target: 'http://localhost:5775', changeOrigin: true, - secure: true, + secure: false, + timeout: 30000, + proxyTimeout: 60000, }, }, }, diff --git a/web/src/components/AutoDivinationPage.tsx b/web/src/components/AutoDivinationPage.tsx index 4866da6..3fd8abb 100644 --- a/web/src/components/AutoDivinationPage.tsx +++ b/web/src/components/AutoDivinationPage.tsx @@ -182,7 +182,7 @@ export default function AutoDivinationPage({ locale, divination: d }: Props) { const cats = useMemo(() => d.categories.split(','), [d.categories]); const navigate = useNavigate(); const [category, setCategory] = useState(cats[0]); - const [question, setQuestion] = useState(text.defaultQuestion); + const [question, setQuestion] = useState(''); const [selectedTime, setSelectedTime] = useState(() => formatDateTimeInput(new Date())); const [yaoResults, setYaoResults] = useState([]); const [guideStep, setGuideStep] = useState(null); @@ -190,6 +190,7 @@ export default function AutoDivinationPage({ locale, divination: d }: Props) { const points = pointsState.data ?? null; const [showProcessing, setShowProcessing] = useState(false); const [showConfirm, setShowConfirm] = useState(false); + const [errorMessage, setErrorMessage] = useState(null); const { userProfile, setUserProfile } = useUserSettings(); // Shake state @@ -436,6 +437,14 @@ export default function AutoDivinationPage({ locale, divination: d }: Props) { } }; + const handleError = (error: Error) => { + setShowProcessing(false); + setErrorMessage(error.message || 'Unknown error'); + }; + + // Check if user has enough points + const hasEnoughPoints = points && points.availableBalance >= (points.runCost ?? 20); + return (
@@ -482,7 +491,7 @@ export default function AutoDivinationPage({ locale, divination: d }: Props) {