feat: integrate CREEM web payment for credits purchase
Replace abandoned iOS App Store route with CREEM Merchant of Record payment integration for web-based credits purchase. Backend changes: - Add CreemClient for CREEM API communication - Add CreemService for checkout creation and webhook handling - Add creem_transactions table for payment tracking - Fix webhook payload parsing (id, order.id, customer.id structure) - Integrate with existing points ledger system Frontend changes: - Display dynamic prices from CREEM API - Support decimal price formatting (e.g., $1.00) - Add checkout flow with redirect to CREEM hosted page
This commit is contained in:
+15
-2
@@ -5,6 +5,7 @@
|
|||||||
# 运行时配置
|
# 运行时配置
|
||||||
############
|
############
|
||||||
ERYAO_RUNTIME__ENVIRONMENT=dev
|
ERYAO_RUNTIME__ENVIRONMENT=dev
|
||||||
|
ERYAO_RUNTIME__DEBUG=true
|
||||||
ERYAO_RUNTIME__LOG_LEVEL=INFO
|
ERYAO_RUNTIME__LOG_LEVEL=INFO
|
||||||
ERYAO_RUNTIME__SQL_LOG_QUERIES=false
|
ERYAO_RUNTIME__SQL_LOG_QUERIES=false
|
||||||
ERYAO_RUNTIME__TRUSTED_PROXY_IPS='["127.0.0.1", "172.18.0.1"]'
|
ERYAO_RUNTIME__TRUSTED_PROXY_IPS='["127.0.0.1", "172.18.0.1"]'
|
||||||
@@ -105,9 +106,21 @@ ERYAO_TEST__CODE=123456
|
|||||||
# Apple IAP 配置
|
# Apple IAP 配置
|
||||||
############
|
############
|
||||||
ERYAO_APPLE_IAP__BUNDLE_ID=com.meeyao.qianwen
|
ERYAO_APPLE_IAP__BUNDLE_ID=com.meeyao.qianwen
|
||||||
# Apple IAP 环境识别。auto 表示以后端验签后的 Apple transaction environment 为准。
|
|
||||||
ERYAO_APPLE_IAP__ENVIRONMENT=auto
|
|
||||||
# Server API 密钥(可选,用于主动查询交易状态)
|
# Server API 密钥(可选,用于主动查询交易状态)
|
||||||
ERYAO_APPLE_IAP__SERVER_API_KEY_ID=
|
ERYAO_APPLE_IAP__SERVER_API_KEY_ID=
|
||||||
ERYAO_APPLE_IAP__SERVER_API_PRIVATE_KEY=
|
ERYAO_APPLE_IAP__SERVER_API_PRIVATE_KEY=
|
||||||
ERYAO_APPLE_IAP__SERVER_API_ISSUER_ID=
|
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://<your-domain>/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
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -232,6 +232,13 @@ class AppleIapSettings(BaseModel):
|
|||||||
server_api_private_key: SecretStr | None = None
|
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]:
|
def _resolve_env_files() -> list[str]:
|
||||||
"""Resolve env files in order: .env.local overrides .env"""
|
"""Resolve env files in order: .env.local overrides .env"""
|
||||||
current = Path(__file__).resolve()
|
current = Path(__file__).resolve()
|
||||||
@@ -280,6 +287,7 @@ class Settings(BaseSettings):
|
|||||||
agent_runtime: AgentRuntimeSettings = Field(default_factory=AgentRuntimeSettings)
|
agent_runtime: AgentRuntimeSettings = Field(default_factory=AgentRuntimeSettings)
|
||||||
points_policy: PointsPolicySettings = Field(default_factory=PointsPolicySettings)
|
points_policy: PointsPolicySettings = Field(default_factory=PointsPolicySettings)
|
||||||
apple_iap: AppleIapSettings = Field(default_factory=AppleIapSettings)
|
apple_iap: AppleIapSettings = Field(default_factory=AppleIapSettings)
|
||||||
|
creem: CreemSettings = Field(default_factory=CreemSettings)
|
||||||
feedback_report: FeedbackReportSettings = Field(
|
feedback_report: FeedbackReportSettings = Field(
|
||||||
default_factory=FeedbackReportSettings
|
default_factory=FeedbackReportSettings
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,24 +1,28 @@
|
|||||||
product_mappings:
|
product_mappings:
|
||||||
new_user_pack:
|
new_user_pack:
|
||||||
app_store_product_id: com.meeyao.qianwen.new_user_pack
|
app_store_product_id: com.meeyao.qianwen.new_user_pack
|
||||||
|
creem_product_id: prod_2x9LzVlR3ot1HLgbIZALPd
|
||||||
credits: 60
|
credits: 60
|
||||||
type: starter
|
type: starter
|
||||||
sort_order: 0
|
sort_order: 0
|
||||||
enabled: true
|
enabled: true
|
||||||
starter_pack:
|
starter_pack:
|
||||||
app_store_product_id: com.meeyao.qianwen.starter_pack
|
app_store_product_id: com.meeyao.qianwen.starter_pack
|
||||||
|
creem_product_id: prod_697ay0pXFXrBYEVC7HS0MR
|
||||||
credits: 100
|
credits: 100
|
||||||
type: regular
|
type: regular
|
||||||
sort_order: 10
|
sort_order: 10
|
||||||
enabled: true
|
enabled: true
|
||||||
popular_pack:
|
popular_pack:
|
||||||
app_store_product_id: com.meeyao.qianwen.popular_pack
|
app_store_product_id: com.meeyao.qianwen.popular_pack
|
||||||
|
creem_product_id: prod_5ivxlPnZWN6dIhnOxctThy
|
||||||
credits: 210
|
credits: 210
|
||||||
type: regular
|
type: regular
|
||||||
sort_order: 20
|
sort_order: 20
|
||||||
enabled: true
|
enabled: true
|
||||||
premium_pack:
|
premium_pack:
|
||||||
app_store_product_id: com.meeyao.qianwen.premium_pack
|
app_store_product_id: com.meeyao.qianwen.premium_pack
|
||||||
|
creem_product_id: prod_2L13k70jlpPYkdHhexHP2s
|
||||||
credits: 415
|
credits: 415
|
||||||
type: regular
|
type: regular
|
||||||
sort_order: 30
|
sort_order: 30
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ engine: AsyncEngine = create_async_engine(
|
|||||||
config.database_url,
|
config.database_url,
|
||||||
echo=config.runtime.sql_log_queries,
|
echo=config.runtime.sql_log_queries,
|
||||||
pool_pre_ping=True,
|
pool_pre_ping=True,
|
||||||
|
pool_size=3,
|
||||||
|
max_overflow=0,
|
||||||
|
pool_timeout=10,
|
||||||
)
|
)
|
||||||
|
|
||||||
AsyncSessionLocal: async_sessionmaker[AsyncSession] = async_sessionmaker(
|
AsyncSessionLocal: async_sessionmaker[AsyncSession] = async_sessionmaker(
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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()
|
||||||
@@ -5,6 +5,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
|
|
||||||
from core.db import get_db
|
from core.db import get_db
|
||||||
from v1.payments.apple_verifier import AppleJwsVerifier
|
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.repository import PaymentRepository
|
||||||
from v1.payments.service import PaymentService
|
from v1.payments.service import PaymentService
|
||||||
from v1.points.repository import PointsRepository
|
from v1.points.repository import PointsRepository
|
||||||
@@ -19,3 +21,14 @@ def get_payment_service(session: AsyncSession = Depends(get_db)) -> PaymentServi
|
|||||||
points_repo=points_repo,
|
points_repo=points_repo,
|
||||||
verifier=verifier,
|
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,
|
||||||
|
)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from sqlalchemy.dialects.postgresql import insert
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from models.apple_iap_transaction import AppleIapTransaction
|
from models.apple_iap_transaction import AppleIapTransaction
|
||||||
|
from models.creem_transaction import CreemTransaction
|
||||||
from models.register_bonus_claims import RegisterBonusClaims
|
from models.register_bonus_claims import RegisterBonusClaims
|
||||||
from models.user_points import UserPoints
|
from models.user_points import UserPoints
|
||||||
|
|
||||||
@@ -84,5 +85,17 @@ class PaymentRepository:
|
|||||||
raise RuntimeError("Failed to upsert register bonus claim")
|
raise RuntimeError("Failed to upsert register bonus claim")
|
||||||
return 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:
|
async def commit(self) -> None:
|
||||||
await self._session.commit()
|
await self._session.commit()
|
||||||
|
|||||||
@@ -3,16 +3,19 @@ from __future__ import annotations
|
|||||||
import logging
|
import logging
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Response
|
from fastapi import APIRouter, Depends, Request, Response
|
||||||
|
|
||||||
from core.auth.models import CurrentUser
|
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 (
|
from v1.payments.schemas import (
|
||||||
AppleServerNotificationRequest,
|
AppleServerNotificationRequest,
|
||||||
|
CreateCheckoutRequest,
|
||||||
|
CreateCheckoutResponse,
|
||||||
VerifyTransactionRequest,
|
VerifyTransactionRequest,
|
||||||
VerifyTransactionResponse,
|
VerifyTransactionResponse,
|
||||||
)
|
)
|
||||||
from v1.payments.service import PaymentService
|
from v1.payments.service import PaymentService
|
||||||
|
from v1.payments.creem_service import CreemService
|
||||||
from v1.users.dependencies import get_current_user
|
from v1.users.dependencies import get_current_user
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -43,3 +46,34 @@ async def handle_apple_server_notification(
|
|||||||
) -> Response:
|
) -> Response:
|
||||||
await service.handle_server_notification(signed_payload=request.signed_payload)
|
await service.handle_server_notification(signed_payload=request.signed_payload)
|
||||||
return Response(status_code=200)
|
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)
|
||||||
|
|||||||
@@ -45,3 +45,16 @@ class AppleServerNotificationRequest(BaseModel):
|
|||||||
model_config = ConfigDict(extra="allow")
|
model_config = ConfigDict(extra="allow")
|
||||||
|
|
||||||
signed_payload: str = Field(alias="signedPayload", default="")
|
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")
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ logger = logging.getLogger(__name__)
|
|||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class ProductMapping:
|
class ProductMapping:
|
||||||
app_store_product_id: str
|
app_store_product_id: str
|
||||||
|
creem_product_id: str | None
|
||||||
credits: int
|
credits: int
|
||||||
type: str
|
type: str
|
||||||
sort_order: int = 0
|
sort_order: int = 0
|
||||||
@@ -58,7 +59,8 @@ def _load_product_mappings() -> dict[str, ProductMapping]:
|
|||||||
product_mappings: Any = raw.get("product_mappings", {})
|
product_mappings: Any = raw.get("product_mappings", {})
|
||||||
for code, entry in product_mappings.items():
|
for code, entry in product_mappings.items():
|
||||||
mappings[str(code)] = ProductMapping(
|
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"]),
|
credits=int(entry["credits"]),
|
||||||
type=str(entry["type"]),
|
type=str(entry["type"]),
|
||||||
sort_order=int(entry.get("sort_order", 0)),
|
sort_order=int(entry.get("sort_order", 0)),
|
||||||
|
|||||||
@@ -3,10 +3,18 @@ from __future__ import annotations
|
|||||||
from fastapi import Depends
|
from fastapi import Depends
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from core.config.settings import config
|
||||||
from core.db import get_db
|
from core.db import get_db
|
||||||
|
from v1.payments.creem_client import CreemClient
|
||||||
from v1.points.repository import PointsRepository
|
from v1.points.repository import PointsRepository
|
||||||
from v1.points.service import PointsService
|
from v1.points.service import PointsService
|
||||||
|
|
||||||
|
|
||||||
def get_points_service(session: AsyncSession = Depends(get_db)) -> 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,
|
||||||
|
)
|
||||||
|
|||||||
@@ -67,11 +67,14 @@ async def get_available_packages(
|
|||||||
PackageInfo(
|
PackageInfo(
|
||||||
productCode=pkg.product_code,
|
productCode=pkg.product_code,
|
||||||
appStoreProductId=pkg.app_store_product_id,
|
appStoreProductId=pkg.app_store_product_id,
|
||||||
|
creemProductId=pkg.creem_product_id,
|
||||||
type=pkg.type,
|
type=pkg.type,
|
||||||
credits=pkg.credits,
|
credits=pkg.credits,
|
||||||
isStarter=pkg.is_starter,
|
isStarter=pkg.is_starter,
|
||||||
starterEligible=pkg.starter_eligible,
|
starterEligible=pkg.starter_eligible,
|
||||||
sortOrder=pkg.sort_order,
|
sortOrder=pkg.sort_order,
|
||||||
|
priceCents=pkg.price_cents,
|
||||||
|
currency=pkg.currency,
|
||||||
)
|
)
|
||||||
for pkg in result.packages
|
for pkg in result.packages
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -19,14 +19,19 @@ class PackageInfo(BaseModel):
|
|||||||
model_config = ConfigDict(populate_by_name=True, serialize_by_alias=True)
|
model_config = ConfigDict(populate_by_name=True, serialize_by_alias=True)
|
||||||
|
|
||||||
product_code: str = Field(alias="productCode", min_length=1, max_length=128)
|
product_code: str = Field(alias="productCode", min_length=1, max_length=128)
|
||||||
app_store_product_id: str = Field(
|
app_store_product_id: str | None = Field(
|
||||||
alias="appStoreProductId", min_length=1, max_length=256
|
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"]
|
type: Literal["starter", "regular"]
|
||||||
credits: int = Field(ge=1)
|
credits: int = Field(ge=1)
|
||||||
is_starter: bool = Field(alias="isStarter")
|
is_starter: bool = Field(alias="isStarter")
|
||||||
starter_eligible: bool = Field(alias="starterEligible")
|
starter_eligible: bool = Field(alias="starterEligible")
|
||||||
sort_order: int = Field(alias="sortOrder", ge=0)
|
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):
|
class PackagesResponse(BaseModel):
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ from schemas.domain.points import (
|
|||||||
from schemas.enums import PointsBizType, PointsChangeType, PointsOperatorType
|
from schemas.enums import PointsBizType, PointsChangeType, PointsOperatorType
|
||||||
from schemas.domain.points import ApplyPointsChangeCommand
|
from schemas.domain.points import ApplyPointsChangeCommand
|
||||||
from v1.payments.service import _load_product_mappings
|
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.repository import PointsRepository
|
||||||
from v1.points.schemas import LedgerItem
|
from v1.points.schemas import LedgerItem
|
||||||
|
|
||||||
@@ -65,12 +67,15 @@ class RegisterBonusResult:
|
|||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class PackageInfoResult:
|
class PackageInfoResult:
|
||||||
product_code: str
|
product_code: str
|
||||||
app_store_product_id: str
|
app_store_product_id: str | None
|
||||||
|
creem_product_id: str | None
|
||||||
type: Literal["starter", "regular"]
|
type: Literal["starter", "regular"]
|
||||||
credits: int
|
credits: int
|
||||||
sort_order: int
|
sort_order: int
|
||||||
is_starter: bool
|
is_starter: bool
|
||||||
starter_eligible: bool
|
starter_eligible: bool
|
||||||
|
price_cents: int | None = None
|
||||||
|
currency: str | None = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -79,8 +84,13 @@ class PackagesResult:
|
|||||||
|
|
||||||
|
|
||||||
class PointsService:
|
class PointsService:
|
||||||
def __init__(self, repository: PointsRepository) -> None:
|
def __init__(
|
||||||
|
self,
|
||||||
|
repository: PointsRepository,
|
||||||
|
creem_client: CreemClient | None = None,
|
||||||
|
) -> None:
|
||||||
self._repository = repository
|
self._repository = repository
|
||||||
|
self._creem_client = creem_client
|
||||||
|
|
||||||
async def grant_register_bonus_if_eligible(
|
async def grant_register_bonus_if_eligible(
|
||||||
self,
|
self,
|
||||||
@@ -453,6 +463,17 @@ class PointsService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
product_mappings = _load_product_mappings()
|
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] = []
|
packages: list[PackageInfoResult] = []
|
||||||
for product_code, mapping in product_mappings.items():
|
for product_code, mapping in product_mappings.items():
|
||||||
@@ -464,15 +485,25 @@ class PointsService:
|
|||||||
if pkg_type == "starter" and has_starter:
|
if pkg_type == "starter" and has_starter:
|
||||||
continue
|
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(
|
packages.append(
|
||||||
PackageInfoResult(
|
PackageInfoResult(
|
||||||
product_code=product_code,
|
product_code=product_code,
|
||||||
app_store_product_id=mapping.app_store_product_id,
|
app_store_product_id=mapping.app_store_product_id,
|
||||||
|
creem_product_id=creem_product_id,
|
||||||
type=pkg_type,
|
type=pkg_type,
|
||||||
credits=mapping.credits,
|
credits=mapping.credits,
|
||||||
sort_order=mapping.sort_order,
|
sort_order=mapping.sort_order,
|
||||||
is_starter=pkg_type == "starter",
|
is_starter=pkg_type == "starter",
|
||||||
starter_eligible=(pkg_type == "starter" and not has_starter),
|
starter_eligible=(pkg_type == "starter" and not has_starter),
|
||||||
|
price_cents=price_cents,
|
||||||
|
currency=currency,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -20,11 +20,14 @@ export default defineConfig({
|
|||||||
vite: {
|
vite: {
|
||||||
plugins: [tailwindcss()],
|
plugins: [tailwindcss()],
|
||||||
server: {
|
server: {
|
||||||
|
port: 4322,
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
'/api': {
|
||||||
target: 'https://api.meeyao.com',
|
target: 'http://localhost:5775',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
secure: true,
|
secure: false,
|
||||||
|
timeout: 30000,
|
||||||
|
proxyTimeout: 60000,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -182,7 +182,7 @@ export default function AutoDivinationPage({ locale, divination: d }: Props) {
|
|||||||
const cats = useMemo(() => d.categories.split(','), [d.categories]);
|
const cats = useMemo(() => d.categories.split(','), [d.categories]);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [category, setCategory] = useState<string>(cats[0]);
|
const [category, setCategory] = useState<string>(cats[0]);
|
||||||
const [question, setQuestion] = useState<string>(text.defaultQuestion);
|
const [question, setQuestion] = useState<string>('');
|
||||||
const [selectedTime, setSelectedTime] = useState(() => formatDateTimeInput(new Date()));
|
const [selectedTime, setSelectedTime] = useState(() => formatDateTimeInput(new Date()));
|
||||||
const [yaoResults, setYaoResults] = useState<YaoType[]>([]);
|
const [yaoResults, setYaoResults] = useState<YaoType[]>([]);
|
||||||
const [guideStep, setGuideStep] = useState<number | null>(null);
|
const [guideStep, setGuideStep] = useState<number | null>(null);
|
||||||
@@ -190,6 +190,7 @@ export default function AutoDivinationPage({ locale, divination: d }: Props) {
|
|||||||
const points = pointsState.data ?? null;
|
const points = pointsState.data ?? null;
|
||||||
const [showProcessing, setShowProcessing] = useState(false);
|
const [showProcessing, setShowProcessing] = useState(false);
|
||||||
const [showConfirm, setShowConfirm] = useState(false);
|
const [showConfirm, setShowConfirm] = useState(false);
|
||||||
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||||
const { userProfile, setUserProfile } = useUserSettings();
|
const { userProfile, setUserProfile } = useUserSettings();
|
||||||
|
|
||||||
// Shake state
|
// 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 (
|
return (
|
||||||
<div ref={scrollContainerRef} className="relative flex min-h-full flex-col gap-[22px]">
|
<div ref={scrollContainerRef} className="relative flex min-h-full flex-col gap-[22px]">
|
||||||
<div className="flex items-center justify-between gap-5">
|
<div className="flex items-center justify-between gap-5">
|
||||||
@@ -482,7 +491,7 @@ export default function AutoDivinationPage({ locale, divination: d }: Props) {
|
|||||||
<textarea
|
<textarea
|
||||||
value={question}
|
value={question}
|
||||||
onChange={(event) => setQuestion(event.target.value)}
|
onChange={(event) => setQuestion(event.target.value)}
|
||||||
placeholder={d.questionPlaceholder}
|
placeholder={text.defaultQuestion}
|
||||||
className="min-h-0 flex-1 resize-none rounded-[10px] border border-slate-300 bg-white px-3.5 py-3 text-sm text-[#333333] outline-none focus:border-violet-500"
|
className="min-h-0 flex-1 resize-none rounded-[10px] border border-slate-300 bg-white px-3.5 py-3 text-sm text-[#333333] outline-none focus:border-violet-500"
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
@@ -612,12 +621,10 @@ export default function AutoDivinationPage({ locale, divination: d }: Props) {
|
|||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-40 hidden bg-black/70 xl:block"
|
className="fixed inset-0 z-40 hidden bg-black/70 xl:block"
|
||||||
onClick={() => closeGuide()}
|
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0 z-40 xl:hidden"
|
className="absolute inset-0 z-40 xl:hidden"
|
||||||
style={{ top: 0, height: '100vh' }}
|
style={{ top: 0, height: '100vh' }}
|
||||||
onClick={() => closeGuide()}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -705,10 +712,15 @@ export default function AutoDivinationPage({ locale, divination: d }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="border-t border-slate-200 pt-3 flex justify-between text-sm">
|
<div className="border-t border-slate-200 pt-3 flex justify-between text-sm">
|
||||||
<span className="text-slate-500">{text.confirmRemaining}</span>
|
<span className="text-slate-500">{text.confirmRemaining}</span>
|
||||||
<span className="text-slate-900 font-bold">
|
<span className={`font-bold ${hasEnoughPoints ? 'text-slate-900' : 'text-red-500'}`}>
|
||||||
{(points?.availableBalance ?? 0) - (points?.runCost ?? 20)}
|
{(points?.availableBalance ?? 0) - (points?.runCost ?? 20)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
{!hasEnoughPoints && (
|
||||||
|
<p className="text-red-500 text-sm font-medium">
|
||||||
|
{locale === 'en' ? 'Insufficient credits. Please purchase more.' : '积分不足,请先充值。'}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<button
|
<button
|
||||||
@@ -719,7 +731,12 @@ export default function AutoDivinationPage({ locale, divination: d }: Props) {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleConfirm}
|
onClick={handleConfirm}
|
||||||
className="flex-1 h-11 rounded-full bg-violet-600 text-sm font-bold text-white hover:bg-violet-700 transition-colors"
|
disabled={!hasEnoughPoints}
|
||||||
|
className={`flex-1 h-11 rounded-full text-sm font-bold text-white transition-colors ${
|
||||||
|
hasEnoughPoints
|
||||||
|
? 'bg-violet-600 hover:bg-violet-700'
|
||||||
|
: 'bg-slate-300 cursor-not-allowed'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
{text.confirm}
|
{text.confirm}
|
||||||
</button>
|
</button>
|
||||||
@@ -740,9 +757,26 @@ export default function AutoDivinationPage({ locale, divination: d }: Props) {
|
|||||||
}}
|
}}
|
||||||
yaoStates={yaoResults}
|
yaoStates={yaoResults}
|
||||||
onComplete={handleComplete}
|
onComplete={handleComplete}
|
||||||
|
onError={handleError}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Error dialog */}
|
||||||
|
{errorMessage && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||||
|
<div className="bg-white rounded-2xl p-6 w-[400px] max-w-[90vw] flex flex-col gap-5 shadow-xl">
|
||||||
|
<h3 className="text-red-600 text-lg font-bold">{locale === 'en' ? 'Error' : '出错了'}</h3>
|
||||||
|
<p className="text-sm text-slate-600">{errorMessage}</p>
|
||||||
|
<button
|
||||||
|
onClick={() => setErrorMessage(null)}
|
||||||
|
className="h-11 w-full rounded-full bg-slate-100 text-sm font-bold text-slate-700 hover:bg-slate-200 transition-colors"
|
||||||
|
>
|
||||||
|
{locale === 'en' ? 'Close' : '关闭'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Coin spin animation */}
|
{/* Coin spin animation */}
|
||||||
<style>{`
|
<style>{`
|
||||||
@keyframes coin-spin {
|
@keyframes coin-spin {
|
||||||
|
|||||||
@@ -103,8 +103,8 @@ export default function Dashboard({ locale, translations: i18n }: DashboardProps
|
|||||||
{i18n.heroCta}
|
{i18n.heroCta}
|
||||||
</a>
|
</a>
|
||||||
{availablePoints !== undefined && (
|
{availablePoints !== undefined && (
|
||||||
<span className="text-violet-100 text-sm">
|
<span className="text-violet-100 text-base">
|
||||||
{locale === 'en' ? 'Available credits' : '可用积分'}: <strong className="text-white">{availablePoints}</strong>
|
{locale === 'en' ? 'Available credits' : '可用积分'}: <strong className="text-white text-xl">{availablePoints}</strong>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -194,7 +194,7 @@ export default function ManualDivinationPage({ locale, divination: d }: Props) {
|
|||||||
const cats = useMemo(() => d.categories.split(','), [d.categories]);
|
const cats = useMemo(() => d.categories.split(','), [d.categories]);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [category, setCategory] = useState<string>(cats[0]);
|
const [category, setCategory] = useState<string>(cats[0]);
|
||||||
const [question, setQuestion] = useState<string>(text.defaultQuestion);
|
const [question, setQuestion] = useState<string>('');
|
||||||
const [selectedTime, setSelectedTime] = useState(() => formatDateTimeInput(new Date()));
|
const [selectedTime, setSelectedTime] = useState(() => formatDateTimeInput(new Date()));
|
||||||
const [coins, setCoins] = useState<[CoinFace, CoinFace, CoinFace]>(['zi', 'zi', 'zi']);
|
const [coins, setCoins] = useState<[CoinFace, CoinFace, CoinFace]>(['zi', 'zi', 'zi']);
|
||||||
const [yaoResults, setYaoResults] = useState<YaoType[]>([]);
|
const [yaoResults, setYaoResults] = useState<YaoType[]>([]);
|
||||||
@@ -204,6 +204,7 @@ export default function ManualDivinationPage({ locale, divination: d }: Props) {
|
|||||||
const [editingIndex, setEditingIndex] = useState<number | null>(null);
|
const [editingIndex, setEditingIndex] = useState<number | null>(null);
|
||||||
const [showProcessing, setShowProcessing] = useState(false);
|
const [showProcessing, setShowProcessing] = useState(false);
|
||||||
const [showConfirm, setShowConfirm] = useState(false);
|
const [showConfirm, setShowConfirm] = useState(false);
|
||||||
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||||
const { userProfile, setUserProfile } = useUserSettings();
|
const { userProfile, setUserProfile } = useUserSettings();
|
||||||
|
|
||||||
// Refs for guide spotlight positioning
|
// Refs for guide spotlight positioning
|
||||||
@@ -455,6 +456,14 @@ export default function ManualDivinationPage({ 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 (
|
return (
|
||||||
<div ref={scrollContainerRef} className="relative flex min-h-full flex-col gap-[22px]">
|
<div ref={scrollContainerRef} className="relative flex min-h-full flex-col gap-[22px]">
|
||||||
<div className="flex items-center justify-between gap-5">
|
<div className="flex items-center justify-between gap-5">
|
||||||
@@ -501,7 +510,7 @@ export default function ManualDivinationPage({ locale, divination: d }: Props) {
|
|||||||
<textarea
|
<textarea
|
||||||
value={question}
|
value={question}
|
||||||
onChange={(event) => setQuestion(event.target.value)}
|
onChange={(event) => setQuestion(event.target.value)}
|
||||||
placeholder={d.questionPlaceholder}
|
placeholder={text.defaultQuestion}
|
||||||
className="min-h-0 flex-1 resize-none rounded-[10px] border border-slate-300 bg-white px-3.5 py-3 text-sm text-[#333333] outline-none focus:border-violet-500"
|
className="min-h-0 flex-1 resize-none rounded-[10px] border border-slate-300 bg-white px-3.5 py-3 text-sm text-[#333333] outline-none focus:border-violet-500"
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
@@ -613,13 +622,11 @@ export default function ManualDivinationPage({ locale, divination: d }: Props) {
|
|||||||
{/* Dark overlay - fixed for desktop, covers viewport */}
|
{/* Dark overlay - fixed for desktop, covers viewport */}
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-40 hidden bg-black/70 xl:block"
|
className="fixed inset-0 z-40 hidden bg-black/70 xl:block"
|
||||||
onClick={() => closeGuide()}
|
|
||||||
/>
|
/>
|
||||||
{/* Mobile dark overlay - positioned within scroll container */}
|
{/* Mobile dark overlay - positioned within scroll container */}
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0 z-40 xl:hidden"
|
className="absolute inset-0 z-40 xl:hidden"
|
||||||
style={{ top: 0, height: '100vh' }}
|
style={{ top: 0, height: '100vh' }}
|
||||||
onClick={() => closeGuide()}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Spotlight on target element - fixed for desktop, absolute for mobile */}
|
{/* Spotlight on target element - fixed for desktop, absolute for mobile */}
|
||||||
@@ -710,10 +717,15 @@ export default function ManualDivinationPage({ locale, divination: d }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="border-t border-slate-200 pt-3 flex justify-between text-sm">
|
<div className="border-t border-slate-200 pt-3 flex justify-between text-sm">
|
||||||
<span className="text-slate-500">{text.confirmRemaining}</span>
|
<span className="text-slate-500">{text.confirmRemaining}</span>
|
||||||
<span className="text-slate-900 font-bold">
|
<span className={`font-bold ${hasEnoughPoints ? 'text-slate-900' : 'text-red-500'}`}>
|
||||||
{(points?.availableBalance ?? 0) - (points?.runCost ?? 20)}
|
{(points?.availableBalance ?? 0) - (points?.runCost ?? 20)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
{!hasEnoughPoints && (
|
||||||
|
<p className="text-red-500 text-sm font-medium">
|
||||||
|
{locale === 'en' ? 'Insufficient credits. Please purchase more.' : '积分不足,请先充值。'}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<button
|
<button
|
||||||
@@ -724,7 +736,12 @@ export default function ManualDivinationPage({ locale, divination: d }: Props) {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleConfirm}
|
onClick={handleConfirm}
|
||||||
className="flex-1 h-11 rounded-full bg-violet-600 text-sm font-bold text-white hover:bg-violet-700 transition-colors"
|
disabled={!hasEnoughPoints}
|
||||||
|
className={`flex-1 h-11 rounded-full text-sm font-bold text-white transition-colors ${
|
||||||
|
hasEnoughPoints
|
||||||
|
? 'bg-violet-600 hover:bg-violet-700'
|
||||||
|
: 'bg-slate-300 cursor-not-allowed'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
{text.confirm}
|
{text.confirm}
|
||||||
</button>
|
</button>
|
||||||
@@ -745,8 +762,25 @@ export default function ManualDivinationPage({ locale, divination: d }: Props) {
|
|||||||
}}
|
}}
|
||||||
yaoStates={yaoResults}
|
yaoStates={yaoResults}
|
||||||
onComplete={handleComplete}
|
onComplete={handleComplete}
|
||||||
|
onError={handleError}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Error dialog */}
|
||||||
|
{errorMessage && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||||
|
<div className="bg-white rounded-2xl p-6 w-[400px] max-w-[90vw] flex flex-col gap-5 shadow-xl">
|
||||||
|
<h3 className="text-red-600 text-lg font-bold">{locale === 'en' ? 'Error' : '出错了'}</h3>
|
||||||
|
<p className="text-sm text-slate-600">{errorMessage}</p>
|
||||||
|
<button
|
||||||
|
onClick={() => setErrorMessage(null)}
|
||||||
|
className="h-11 w-full rounded-full bg-slate-100 text-sm font-bold text-slate-700 hover:bg-slate-200 transition-colors"
|
||||||
|
>
|
||||||
|
{locale === 'en' ? 'Close' : '关闭'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { logout, getAuth } from '../lib/auth';
|
import { useState } from 'react';
|
||||||
|
import { logout, getAuth, clearAuth, redirectToLogin } from '../lib/auth';
|
||||||
import { usePoints, useProfile } from '../lib/resources';
|
import { usePoints, useProfile } from '../lib/resources';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -13,13 +14,19 @@ export default function SettingsPage({ locale, settings: s }: Props) {
|
|||||||
const profile = profileState.data ?? null;
|
const profile = profileState.data ?? null;
|
||||||
const points = pointsState.data ?? null;
|
const points = pointsState.data ?? null;
|
||||||
const loading = profileState.loading || pointsState.loading;
|
const loading = profileState.loading || pointsState.loading;
|
||||||
|
const [logoutLoading, setLogoutLoading] = useState(false);
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = async () => {
|
||||||
if (confirm(s.logoutConfirm)) {
|
if (logoutLoading) return;
|
||||||
logout().finally(() => {
|
if (!confirm(s.logoutConfirm)) return;
|
||||||
window.location.href = `/${locale}/login`;
|
|
||||||
});
|
setLogoutLoading(true);
|
||||||
}
|
// Clear local auth immediately and redirect
|
||||||
|
clearAuth();
|
||||||
|
// Fire backend logout in background (don't wait)
|
||||||
|
logout().catch(() => {});
|
||||||
|
// Redirect to login
|
||||||
|
redirectToLogin();
|
||||||
};
|
};
|
||||||
|
|
||||||
const authEmail = getAuth()?.user?.email;
|
const authEmail = getAuth()?.user?.email;
|
||||||
@@ -198,9 +205,12 @@ export default function SettingsPage({ locale, settings: s }: Props) {
|
|||||||
{/* Logout Button */}
|
{/* Logout Button */}
|
||||||
<button
|
<button
|
||||||
onClick={handleLogout}
|
onClick={handleLogout}
|
||||||
className="bg-white rounded-2xl px-5 py-3.5 border border-red-200 flex items-center justify-between hover:bg-red-50 transition-colors"
|
disabled={logoutLoading}
|
||||||
|
className={`bg-white rounded-2xl px-5 py-3.5 border border-red-200 flex items-center justify-between hover:bg-red-50 transition-colors ${logoutLoading ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||||
>
|
>
|
||||||
<span className="text-red-500 text-sm font-medium">{s.logout}</span>
|
<span className="text-red-500 text-sm font-medium">
|
||||||
|
{logoutLoading ? (locale === 'en' ? 'Logging out...' : '退出中...') : s.logout}
|
||||||
|
</span>
|
||||||
<span className="material-symbols-rounded text-red-400 text-lg">logout</span>
|
<span className="material-symbols-rounded text-red-400 text-lg">logout</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { usePackages, usePoints } from '../lib/resources';
|
import { usePackages, usePoints } from '../lib/resources';
|
||||||
|
import { createCheckout } from '../lib/api';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
locale: string;
|
locale: string;
|
||||||
@@ -16,7 +17,7 @@ interface PackageDisplay {
|
|||||||
desc: string;
|
desc: string;
|
||||||
featured: boolean;
|
featured: boolean;
|
||||||
productCode: string;
|
productCode: string;
|
||||||
appStoreProductId: string;
|
creemProductId: string | null;
|
||||||
starterEligible: boolean;
|
starterEligible: boolean;
|
||||||
isStarter: boolean;
|
isStarter: boolean;
|
||||||
}
|
}
|
||||||
@@ -29,6 +30,18 @@ const PRODUCT_CODE_MAP: Record<string, string> = {
|
|||||||
'premium_pack': 'p4', // 高频进阶包 415积分
|
'premium_pack': 'p4', // 高频进阶包 415积分
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Format price from cents to display string
|
||||||
|
function formatPrice(cents: number | null, currency: string | null): string {
|
||||||
|
if (cents === null || currency === null) return '';
|
||||||
|
const dollars = cents / 100;
|
||||||
|
return new Intl.NumberFormat('en-US', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: currency,
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
}).format(dollars);
|
||||||
|
}
|
||||||
|
|
||||||
function SidePanel({ s }: { s: Props['store'] }) {
|
function SidePanel({ s }: { s: Props['store'] }) {
|
||||||
return (
|
return (
|
||||||
<div className="w-full xl:w-[320px] bg-white rounded-2xl p-5 border border-slate-200 flex flex-col gap-4 shrink-0 overflow-y-auto">
|
<div className="w-full xl:w-[320px] bg-white rounded-2xl p-5 border border-slate-200 flex flex-col gap-4 shrink-0 overflow-y-auto">
|
||||||
@@ -52,20 +65,23 @@ export default function StorePage({ store: s, pricing: p }: Props) {
|
|||||||
const pointsState = usePoints();
|
const pointsState = usePoints();
|
||||||
const packagesState = usePackages();
|
const packagesState = usePackages();
|
||||||
const points = pointsState.data ?? null;
|
const points = pointsState.data ?? null;
|
||||||
|
const [purchasing, setPurchasing] = useState<string | null>(null);
|
||||||
|
|
||||||
const packages = useMemo<PackageDisplay[]>(() => {
|
const packages = useMemo<PackageDisplay[]>(() => {
|
||||||
const packagesData = packagesState.data;
|
const packagesData = packagesState.data;
|
||||||
if (!packagesData) return [];
|
if (!packagesData) return [];
|
||||||
const displayPkgs: PackageDisplay[] = packagesData.packages.map((pkg) => {
|
const displayPkgs: PackageDisplay[] = packagesData.packages.map((pkg) => {
|
||||||
const key = PRODUCT_CODE_MAP[pkg.productCode] || 'p1';
|
const key = PRODUCT_CODE_MAP[pkg.productCode] || 'p1';
|
||||||
|
const dynamicPrice = formatPrice(pkg.priceCents, pkg.currency);
|
||||||
return {
|
return {
|
||||||
name: p[`${key}Name` as keyof typeof p] || pkg.productCode,
|
name: p[`${key}Name` as keyof typeof p] || pkg.productCode,
|
||||||
badge: pkg.isStarter ? (pkg.starterEligible ? p.p1Badge : '') : '',
|
badge: pkg.isStarter ? (pkg.starterEligible ? p.p1Badge : '') : '',
|
||||||
price: p[`${key}Price` as keyof typeof p] || '',
|
price: dynamicPrice,
|
||||||
credits: `${pkg.credits} ${s.pointsLabel}`,
|
credits: `${pkg.credits} ${s.pointsLabel}`,
|
||||||
desc: p[`${key}Desc` as keyof typeof p] || '',
|
desc: p[`${key}Desc` as keyof typeof p] || '',
|
||||||
featured: pkg.productCode === 'popular_pack',
|
featured: pkg.productCode === 'popular_pack',
|
||||||
productCode: pkg.productCode,
|
productCode: pkg.productCode,
|
||||||
appStoreProductId: pkg.appStoreProductId,
|
creemProductId: pkg.creemProductId,
|
||||||
starterEligible: pkg.starterEligible,
|
starterEligible: pkg.starterEligible,
|
||||||
isStarter: pkg.isStarter,
|
isStarter: pkg.isStarter,
|
||||||
};
|
};
|
||||||
@@ -77,8 +93,21 @@ export default function StorePage({ store: s, pricing: p }: Props) {
|
|||||||
});
|
});
|
||||||
return displayPkgs;
|
return displayPkgs;
|
||||||
}, [packagesState.data, p, s.pointsLabel]);
|
}, [packagesState.data, p, s.pointsLabel]);
|
||||||
|
|
||||||
const loading = pointsState.loading || packagesState.loading;
|
const loading = pointsState.loading || packagesState.loading;
|
||||||
|
|
||||||
|
const handleBuy = async (pkg: PackageDisplay) => {
|
||||||
|
if (!pkg.creemProductId) return;
|
||||||
|
setPurchasing(pkg.productCode);
|
||||||
|
try {
|
||||||
|
const result = await createCheckout(pkg.productCode);
|
||||||
|
window.location.href = result.checkoutUrl;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to create checkout:', error);
|
||||||
|
setPurchasing(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-5 min-h-full">
|
<div className="flex flex-col gap-5 min-h-full">
|
||||||
<h1 className="text-slate-900 text-xl font-bold">{s.title}</h1>
|
<h1 className="text-slate-900 text-xl font-bold">{s.title}</h1>
|
||||||
@@ -130,10 +159,11 @@ export default function StorePage({ store: s, pricing: p }: Props) {
|
|||||||
<p className="text-violet-600 text-sm font-medium">{pkg.credits}</p>
|
<p className="text-violet-600 text-sm font-medium">{pkg.credits}</p>
|
||||||
<p className="text-slate-500 text-sm">{pkg.desc}</p>
|
<p className="text-slate-500 text-sm">{pkg.desc}</p>
|
||||||
<button
|
<button
|
||||||
className={`w-full py-2.5 rounded-lg font-semibold text-sm mt-auto ${pkg.featured ? 'bg-violet-600 text-white hover:bg-violet-700' : 'bg-white text-violet-600 border border-violet-200 hover:bg-violet-50'} transition-colors`}
|
className={`w-full py-2.5 rounded-lg font-semibold text-sm mt-auto ${pkg.featured ? 'bg-violet-600 text-white hover:bg-violet-700' : 'bg-white text-violet-600 border border-violet-200 hover:bg-violet-50'} transition-colors disabled:opacity-50 disabled:cursor-not-allowed`}
|
||||||
disabled={pkg.isStarter && !pkg.starterEligible}
|
disabled={pkg.isStarter && !pkg.starterEligible || purchasing === pkg.productCode || !pkg.creemProductId}
|
||||||
|
onClick={() => handleBuy(pkg)}
|
||||||
>
|
>
|
||||||
{pkg.isStarter && !pkg.starterEligible ? (s.rulesTitle.includes('已购') ? '已购买' : 'Purchased') : p.buyNow}
|
{pkg.isStarter && !pkg.starterEligible ? (s.rulesTitle.includes('已购') ? '已购买' : 'Purchased') : purchasing === pkg.productCode ? '...' : p.buyNow}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ export const API_ROUTES = {
|
|||||||
balance: '/api/v1/points/balance',
|
balance: '/api/v1/points/balance',
|
||||||
packages: '/api/v1/points/packages',
|
packages: '/api/v1/points/packages',
|
||||||
},
|
},
|
||||||
|
payments: {
|
||||||
|
creemCheckout: '/api/v1/payments/creem/checkouts',
|
||||||
|
},
|
||||||
notifications: {
|
notifications: {
|
||||||
list: '/api/v1/notifications',
|
list: '/api/v1/notifications',
|
||||||
unreadCount: '/api/v1/notifications/unread-count',
|
unreadCount: '/api/v1/notifications/unread-count',
|
||||||
|
|||||||
+35
-3
@@ -127,18 +127,30 @@ export interface PointsBalance {
|
|||||||
|
|
||||||
export interface PackageInfo {
|
export interface PackageInfo {
|
||||||
productCode: string;
|
productCode: string;
|
||||||
appStoreProductId: string;
|
appStoreProductId: string | null;
|
||||||
|
creemProductId: string | null;
|
||||||
type: 'starter' | 'regular';
|
type: 'starter' | 'regular';
|
||||||
credits: number;
|
credits: number;
|
||||||
isStarter: boolean;
|
isStarter: boolean;
|
||||||
starterEligible: boolean;
|
starterEligible: boolean;
|
||||||
sortOrder: number;
|
sortOrder: number;
|
||||||
|
priceCents: number | null;
|
||||||
|
currency: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PackagesResponse {
|
export interface PackagesResponse {
|
||||||
packages: PackageInfo[];
|
packages: PackageInfo[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CreateCheckoutRequest {
|
||||||
|
productCode: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateCheckoutResponse {
|
||||||
|
checkoutId: string;
|
||||||
|
checkoutUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
export function getPointsBalance(): Promise<PointsBalance> {
|
export function getPointsBalance(): Promise<PointsBalance> {
|
||||||
return authFetch<PointsBalance>(API_ROUTES.points.balance);
|
return authFetch<PointsBalance>(API_ROUTES.points.balance);
|
||||||
}
|
}
|
||||||
@@ -151,6 +163,13 @@ export function getPackages(): Promise<PackagesResponse> {
|
|||||||
return authFetch<PackagesResponse>(API_ROUTES.points.packages);
|
return authFetch<PackagesResponse>(API_ROUTES.points.packages);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function createCheckout(productCode: string): Promise<CreateCheckoutResponse> {
|
||||||
|
return authFetch<CreateCheckoutResponse>(API_ROUTES.payments.creemCheckout, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ productCode }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// --- Notifications ---
|
// --- Notifications ---
|
||||||
|
|
||||||
export interface NotificationPayloadNone {
|
export interface NotificationPayloadNone {
|
||||||
@@ -569,12 +588,25 @@ function toRfc3339Utc(date: Date): string {
|
|||||||
return date.toISOString();
|
return date.toISOString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Polyfill for crypto.randomUUID in unsupported environments
|
||||||
|
function generateUUID(): string {
|
||||||
|
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
|
||||||
|
return crypto.randomUUID();
|
||||||
|
}
|
||||||
|
// Fallback: RFC 4122 v4 UUID
|
||||||
|
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
||||||
|
const r = (Math.random() * 16) | 0;
|
||||||
|
const v = c === 'x' ? r : (r & 0x3) | 0x8;
|
||||||
|
return v.toString(16);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function enqueueDivinationRun(
|
export async function enqueueDivinationRun(
|
||||||
params: DivinationParams,
|
params: DivinationParams,
|
||||||
yaoStates: YaoType[]
|
yaoStates: YaoType[]
|
||||||
): Promise<RunAcceptedData> {
|
): Promise<RunAcceptedData> {
|
||||||
const threadId = crypto.randomUUID();
|
const threadId = generateUUID();
|
||||||
const runId = crypto.randomUUID();
|
const runId = generateUUID();
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
threadId,
|
threadId,
|
||||||
|
|||||||
Reference in New Issue
Block a user