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:
zl-q
2026-05-11 18:38:21 +08:00
parent 3ff33640f4
commit f07e307e82
25 changed files with 989 additions and 45 deletions
+15 -2
View File
@@ -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://<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")
+8
View File
@@ -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
)
@@ -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
+3
View File
@@ -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(
+74
View File
@@ -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)
+135
View File
@@ -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)
+371
View File
@@ -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()
+13
View File
@@ -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,
)
+13
View File
@@ -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()
+36 -2
View File
@@ -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)
+13
View File
@@ -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")
+3 -1
View File
@@ -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)),
+9 -1
View File
@@ -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,
)
+3
View File
@@ -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
],
+7 -2
View File
@@ -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):
+33 -2
View File
@@ -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,
)
)
+5 -2
View File
@@ -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,
},
},
},
+40 -6
View File
@@ -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<string>(cats[0]);
const [question, setQuestion] = useState<string>(text.defaultQuestion);
const [question, setQuestion] = useState<string>('');
const [selectedTime, setSelectedTime] = useState(() => formatDateTimeInput(new Date()));
const [yaoResults, setYaoResults] = useState<YaoType[]>([]);
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 [showProcessing, setShowProcessing] = useState(false);
const [showConfirm, setShowConfirm] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(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 (
<div ref={scrollContainerRef} className="relative flex min-h-full flex-col gap-[22px]">
<div className="flex items-center justify-between gap-5">
@@ -482,7 +491,7 @@ export default function AutoDivinationPage({ locale, divination: d }: Props) {
<textarea
value={question}
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"
/>
</section>
@@ -612,12 +621,10 @@ export default function AutoDivinationPage({ locale, divination: d }: Props) {
<>
<div
className="fixed inset-0 z-40 hidden bg-black/70 xl:block"
onClick={() => closeGuide()}
/>
<div
className="absolute inset-0 z-40 xl:hidden"
style={{ top: 0, height: '100vh' }}
onClick={() => closeGuide()}
/>
<div
@@ -705,10 +712,15 @@ export default function AutoDivinationPage({ locale, divination: d }: Props) {
</div>
<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-900 font-bold">
<span className={`font-bold ${hasEnoughPoints ? 'text-slate-900' : 'text-red-500'}`}>
{(points?.availableBalance ?? 0) - (points?.runCost ?? 20)}
</span>
</div>
{!hasEnoughPoints && (
<p className="text-red-500 text-sm font-medium">
{locale === 'en' ? 'Insufficient credits. Please purchase more.' : '积分不足,请先充值。'}
</p>
)}
</div>
<div className="flex gap-3">
<button
@@ -719,7 +731,12 @@ export default function AutoDivinationPage({ locale, divination: d }: Props) {
</button>
<button
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}
</button>
@@ -740,9 +757,26 @@ export default function AutoDivinationPage({ locale, divination: d }: Props) {
}}
yaoStates={yaoResults}
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 */}
<style>{`
@keyframes coin-spin {
+2 -2
View File
@@ -103,8 +103,8 @@ export default function Dashboard({ locale, translations: i18n }: DashboardProps
{i18n.heroCta}
</a>
{availablePoints !== undefined && (
<span className="text-violet-100 text-sm">
{locale === 'en' ? 'Available credits' : '可用积分'}: <strong className="text-white">{availablePoints}</strong>
<span className="text-violet-100 text-base">
{locale === 'en' ? 'Available credits' : '可用积分'}: <strong className="text-white text-xl">{availablePoints}</strong>
</span>
)}
</div>
+40 -6
View File
@@ -194,7 +194,7 @@ export default function ManualDivinationPage({ locale, divination: d }: Props) {
const cats = useMemo(() => d.categories.split(','), [d.categories]);
const navigate = useNavigate();
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 [coins, setCoins] = useState<[CoinFace, CoinFace, CoinFace]>(['zi', 'zi', 'zi']);
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 [showProcessing, setShowProcessing] = useState(false);
const [showConfirm, setShowConfirm] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const { userProfile, setUserProfile } = useUserSettings();
// 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 (
<div ref={scrollContainerRef} className="relative flex min-h-full flex-col gap-[22px]">
<div className="flex items-center justify-between gap-5">
@@ -501,7 +510,7 @@ export default function ManualDivinationPage({ locale, divination: d }: Props) {
<textarea
value={question}
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"
/>
</section>
@@ -613,13 +622,11 @@ export default function ManualDivinationPage({ locale, divination: d }: Props) {
{/* Dark overlay - fixed for desktop, covers viewport */}
<div
className="fixed inset-0 z-40 hidden bg-black/70 xl:block"
onClick={() => closeGuide()}
/>
{/* Mobile dark overlay - positioned within scroll container */}
<div
className="absolute inset-0 z-40 xl:hidden"
style={{ top: 0, height: '100vh' }}
onClick={() => closeGuide()}
/>
{/* Spotlight on target element - fixed for desktop, absolute for mobile */}
@@ -710,10 +717,15 @@ export default function ManualDivinationPage({ locale, divination: d }: Props) {
</div>
<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-900 font-bold">
<span className={`font-bold ${hasEnoughPoints ? 'text-slate-900' : 'text-red-500'}`}>
{(points?.availableBalance ?? 0) - (points?.runCost ?? 20)}
</span>
</div>
{!hasEnoughPoints && (
<p className="text-red-500 text-sm font-medium">
{locale === 'en' ? 'Insufficient credits. Please purchase more.' : '积分不足,请先充值。'}
</p>
)}
</div>
<div className="flex gap-3">
<button
@@ -724,7 +736,12 @@ export default function ManualDivinationPage({ locale, divination: d }: Props) {
</button>
<button
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}
</button>
@@ -745,8 +762,25 @@ export default function ManualDivinationPage({ locale, divination: d }: Props) {
}}
yaoStates={yaoResults}
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>
);
}
+19 -9
View File
@@ -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';
interface Props {
@@ -13,13 +14,19 @@ export default function SettingsPage({ locale, settings: s }: Props) {
const profile = profileState.data ?? null;
const points = pointsState.data ?? null;
const loading = profileState.loading || pointsState.loading;
const [logoutLoading, setLogoutLoading] = useState(false);
const handleLogout = () => {
if (confirm(s.logoutConfirm)) {
logout().finally(() => {
window.location.href = `/${locale}/login`;
});
}
const handleLogout = async () => {
if (logoutLoading) return;
if (!confirm(s.logoutConfirm)) return;
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;
@@ -198,9 +205,12 @@ export default function SettingsPage({ locale, settings: s }: Props) {
{/* Logout Button */}
<button
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>
</button>
</div>
+37 -7
View File
@@ -1,5 +1,6 @@
import { useMemo } from 'react';
import { useMemo, useState } from 'react';
import { usePackages, usePoints } from '../lib/resources';
import { createCheckout } from '../lib/api';
interface Props {
locale: string;
@@ -16,7 +17,7 @@ interface PackageDisplay {
desc: string;
featured: boolean;
productCode: string;
appStoreProductId: string;
creemProductId: string | null;
starterEligible: boolean;
isStarter: boolean;
}
@@ -29,6 +30,18 @@ const PRODUCT_CODE_MAP: Record<string, string> = {
'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'] }) {
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">
@@ -52,20 +65,23 @@ export default function StorePage({ store: s, pricing: p }: Props) {
const pointsState = usePoints();
const packagesState = usePackages();
const points = pointsState.data ?? null;
const [purchasing, setPurchasing] = useState<string | null>(null);
const packages = useMemo<PackageDisplay[]>(() => {
const packagesData = packagesState.data;
if (!packagesData) return [];
const displayPkgs: PackageDisplay[] = packagesData.packages.map((pkg) => {
const key = PRODUCT_CODE_MAP[pkg.productCode] || 'p1';
const dynamicPrice = formatPrice(pkg.priceCents, pkg.currency);
return {
name: p[`${key}Name` as keyof typeof p] || pkg.productCode,
badge: pkg.isStarter ? (pkg.starterEligible ? p.p1Badge : '') : '',
price: p[`${key}Price` as keyof typeof p] || '',
price: dynamicPrice,
credits: `${pkg.credits} ${s.pointsLabel}`,
desc: p[`${key}Desc` as keyof typeof p] || '',
featured: pkg.productCode === 'popular_pack',
productCode: pkg.productCode,
appStoreProductId: pkg.appStoreProductId,
creemProductId: pkg.creemProductId,
starterEligible: pkg.starterEligible,
isStarter: pkg.isStarter,
};
@@ -77,8 +93,21 @@ export default function StorePage({ store: s, pricing: p }: Props) {
});
return displayPkgs;
}, [packagesState.data, p, s.pointsLabel]);
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 (
<div className="flex flex-col gap-5 min-h-full">
<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-slate-500 text-sm">{pkg.desc}</p>
<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`}
disabled={pkg.isStarter && !pkg.starterEligible}
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 || 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>
</div>
))}
+3
View File
@@ -16,6 +16,9 @@ export const API_ROUTES = {
balance: '/api/v1/points/balance',
packages: '/api/v1/points/packages',
},
payments: {
creemCheckout: '/api/v1/payments/creem/checkouts',
},
notifications: {
list: '/api/v1/notifications',
unreadCount: '/api/v1/notifications/unread-count',
+35 -3
View File
@@ -127,18 +127,30 @@ export interface PointsBalance {
export interface PackageInfo {
productCode: string;
appStoreProductId: string;
appStoreProductId: string | null;
creemProductId: string | null;
type: 'starter' | 'regular';
credits: number;
isStarter: boolean;
starterEligible: boolean;
sortOrder: number;
priceCents: number | null;
currency: string | null;
}
export interface PackagesResponse {
packages: PackageInfo[];
}
export interface CreateCheckoutRequest {
productCode: string;
}
export interface CreateCheckoutResponse {
checkoutId: string;
checkoutUrl: string;
}
export function getPointsBalance(): Promise<PointsBalance> {
return authFetch<PointsBalance>(API_ROUTES.points.balance);
}
@@ -151,6 +163,13 @@ export function getPackages(): Promise<PackagesResponse> {
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 ---
export interface NotificationPayloadNone {
@@ -569,12 +588,25 @@ function toRfc3339Utc(date: Date): string {
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(
params: DivinationParams,
yaoStates: YaoType[]
): Promise<RunAcceptedData> {
const threadId = crypto.randomUUID();
const runId = crypto.randomUUID();
const threadId = generateUUID();
const runId = generateUUID();
const payload = {
threadId,