Add avatar upload, harden payments, and ebook checkout.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
f215b684f6
commit
8b80d18006
@ -2,7 +2,7 @@
|
|||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.routers import api, auth, community, media, meetup_auth, payment, platform, social
|
from app.routers import api, auth, community, ebook, media, meetup_auth, payment, platform, social
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="nomadro API",
|
title="nomadro API",
|
||||||
@ -24,6 +24,7 @@ app.include_router(meetup_auth.router, prefix="/api/v1")
|
|||||||
app.include_router(social.router, prefix="/api/v1")
|
app.include_router(social.router, prefix="/api/v1")
|
||||||
app.include_router(community.router, prefix="/api/v1")
|
app.include_router(community.router, prefix="/api/v1")
|
||||||
app.include_router(payment.router, prefix="/api/v1")
|
app.include_router(payment.router, prefix="/api/v1")
|
||||||
|
app.include_router(ebook.router, prefix="/api/v1")
|
||||||
app.include_router(platform.router, prefix="/api/v1")
|
app.include_router(platform.router, prefix="/api/v1")
|
||||||
app.include_router(media.router, prefix="/api/v1")
|
app.include_router(media.router, prefix="/api/v1")
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
from fastapi import APIRouter, Header, HTTPException
|
from fastapi import APIRouter, Header, HTTPException
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
@ -10,6 +11,7 @@ from app.schemas import (
|
|||||||
from app.services.auth import (
|
from app.services.auth import (
|
||||||
get_favorites, get_plan, get_user_by_token,
|
get_favorites, get_plan, get_user_by_token,
|
||||||
login_demo, login_google, login_user, register_user, save_plan, toggle_favorite,
|
login_demo, login_google, login_user, register_user, save_plan, toggle_favorite,
|
||||||
|
update_profile,
|
||||||
)
|
)
|
||||||
from app.services.pocketbase import pb
|
from app.services.pocketbase import pb
|
||||||
|
|
||||||
@ -41,6 +43,26 @@ async def me(authorization: str | None = Header(None)):
|
|||||||
return UserProfile(**user)
|
return UserProfile(**user)
|
||||||
|
|
||||||
|
|
||||||
|
class ProfileUpdateBody(BaseModel):
|
||||||
|
name: str | None = None
|
||||||
|
avatar: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/me", response_model=UserProfile)
|
||||||
|
async def patch_me(body: ProfileUpdateBody, authorization: str | None = Header(None)):
|
||||||
|
token = _extract_token(authorization)
|
||||||
|
if not get_user_by_token(token):
|
||||||
|
raise HTTPException(401, "未登录")
|
||||||
|
# Empty patch → return current profile (used by refreshUser)
|
||||||
|
if body.name is None and body.avatar is None:
|
||||||
|
user = get_user_by_token(token)
|
||||||
|
return UserProfile(**user)
|
||||||
|
updated = update_profile(token, name=body.name, avatar=body.avatar)
|
||||||
|
if not updated:
|
||||||
|
raise HTTPException(400, "更新失败")
|
||||||
|
return UserProfile(**updated)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/demo", response_model=AuthResponse)
|
@router.get("/demo", response_model=AuthResponse)
|
||||||
async def demo_login():
|
async def demo_login():
|
||||||
"""一键体验演示账号"""
|
"""一键体验演示账号"""
|
||||||
|
|||||||
45
backend/app/routers/ebook.py
Normal file
45
backend/app/routers/ebook.py
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
"""Ebook purchase entitlements + download links (FastAPI only)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Header, HTTPException
|
||||||
|
|
||||||
|
from app.services.auth import get_user_by_token
|
||||||
|
from app.services import social_store
|
||||||
|
from app.services.payment_service import EBOOK_EPUB_URL, EBOOK_PDF_URL, PAYMENT_EBOOK_AMOUNT
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/ebook", tags=["ebook"])
|
||||||
|
|
||||||
|
|
||||||
|
def _user(authorization: str | None) -> dict:
|
||||||
|
if not authorization or not authorization.startswith("Bearer "):
|
||||||
|
raise HTTPException(401, "请先登录")
|
||||||
|
user = get_user_by_token(authorization[7:])
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(401, "请先登录")
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/status")
|
||||||
|
async def ebook_status(authorization: str | None = Header(None)):
|
||||||
|
user = _user(authorization)
|
||||||
|
owned = social_store.has_ebook(user["id"])
|
||||||
|
return {
|
||||||
|
"owned": owned,
|
||||||
|
"amount_fen": PAYMENT_EBOOK_AMOUNT,
|
||||||
|
"amount_yuan": f"{PAYMENT_EBOOK_AMOUNT / 100:.2f}",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/downloads")
|
||||||
|
async def ebook_downloads(authorization: str | None = Header(None)):
|
||||||
|
user = _user(authorization)
|
||||||
|
if not social_store.has_ebook(user["id"]):
|
||||||
|
raise HTTPException(403, "尚未购买电子书下载版")
|
||||||
|
files = []
|
||||||
|
if EBOOK_PDF_URL:
|
||||||
|
files.append({"format": "pdf", "label": "PDF", "url": EBOOK_PDF_URL})
|
||||||
|
if EBOOK_EPUB_URL:
|
||||||
|
files.append({"format": "epub", "label": "EPUB", "url": EBOOK_EPUB_URL})
|
||||||
|
if not files:
|
||||||
|
files.append({"format": "online", "label": "在线阅读", "url": "/book/read"})
|
||||||
|
return {"owned": True, "files": files}
|
||||||
@ -9,6 +9,7 @@ from app.services import social_store
|
|||||||
from app.services.payment_service import (
|
from app.services.payment_service import (
|
||||||
DEV_AUTO_PAY,
|
DEV_AUTO_PAY,
|
||||||
PAYMENT_DEFAULT_AMOUNT,
|
PAYMENT_DEFAULT_AMOUNT,
|
||||||
|
PAYMENT_EBOOK_AMOUNT,
|
||||||
PAYMENT_JOIN_AMOUNT,
|
PAYMENT_JOIN_AMOUNT,
|
||||||
create_pay_redirect,
|
create_pay_redirect,
|
||||||
parse_notify_order_id,
|
parse_notify_order_id,
|
||||||
@ -44,11 +45,20 @@ async def create_payment(
|
|||||||
authorization: str | None = Header(None),
|
authorization: str | None = Header(None),
|
||||||
):
|
):
|
||||||
user = _token_user(authorization)
|
user = _token_user(authorization)
|
||||||
amount = PAYMENT_JOIN_AMOUNT if body.pay_type == "join" else PAYMENT_DEFAULT_AMOUNT
|
if body.pay_type == "ebook":
|
||||||
|
amount = PAYMENT_EBOOK_AMOUNT
|
||||||
|
product_name = "nomadro电子书下载版"
|
||||||
|
elif body.pay_type in ("join", "vip"):
|
||||||
|
amount = PAYMENT_JOIN_AMOUNT
|
||||||
|
product_name = "nomadro会员"
|
||||||
|
else:
|
||||||
|
amount = PAYMENT_DEFAULT_AMOUNT
|
||||||
|
product_name = "nomadro订单"
|
||||||
order = social_store.create_order(user["id"], body.pay_type, amount, dev_auto_pay=DEV_AUTO_PAY)
|
order = social_store.create_order(user["id"], body.pay_type, amount, dev_auto_pay=DEV_AUTO_PAY)
|
||||||
return_url = body.return_url or "https://nomadweb.nomadro.com/join/paid"
|
return_url = body.return_url or "https://nomadweb.nomadro.com/join/paid"
|
||||||
ua = request.headers.get("user-agent", "")
|
ua = request.headers.get("user-agent", "")
|
||||||
device = "wechat" if "micromessenger" in ua.lower() else "pc"
|
device = "wechat" if "micromessenger" in ua.lower() else "pc"
|
||||||
|
try:
|
||||||
redirect = await create_pay_redirect(
|
redirect = await create_pay_redirect(
|
||||||
order["id"],
|
order["id"],
|
||||||
amount,
|
amount,
|
||||||
@ -58,7 +68,10 @@ async def create_payment(
|
|||||||
device=device,
|
device=device,
|
||||||
user_agent=ua,
|
user_agent=ua,
|
||||||
client_ip=_client_ip(request),
|
client_ip=_client_ip(request),
|
||||||
|
product_name=product_name,
|
||||||
)
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(503, str(exc)) from exc
|
||||||
return PayCreateResponse(
|
return PayCreateResponse(
|
||||||
order_id=order["id"],
|
order_id=order["id"],
|
||||||
amount=amount,
|
amount=amount,
|
||||||
@ -100,7 +113,7 @@ async def complete_order(
|
|||||||
social_store.mark_order_paid(order_id)
|
social_store.mark_order_paid(order_id)
|
||||||
else:
|
else:
|
||||||
raise HTTPException(402, "订单未支付")
|
raise HTTPException(402, "订单未支付")
|
||||||
return {"success": True, "vip": social_store.is_vip(user["id"])}
|
return {"success": True, "vip": social_store.is_vip(user["id"]), "ebook": social_store.has_ebook(user["id"])}
|
||||||
|
|
||||||
|
|
||||||
async def _notify_handler(provider: str, request: Request) -> PlainTextResponse:
|
async def _notify_handler(provider: str, request: Request) -> PlainTextResponse:
|
||||||
|
|||||||
@ -341,6 +341,35 @@ def save_plan(token: str, items: list, meta: dict, updated_at: int | None = None
|
|||||||
return get_plan(token)
|
return get_plan(token)
|
||||||
|
|
||||||
|
|
||||||
|
def update_profile(token: str, *, name: str | None = None, avatar: str | None = None) -> dict | None:
|
||||||
|
uid = _token_user_id(token)
|
||||||
|
if not uid:
|
||||||
|
return None
|
||||||
|
if use_pb():
|
||||||
|
user = _get_account_by_id(uid)
|
||||||
|
if not user:
|
||||||
|
return None
|
||||||
|
patch: dict[str, Any] = {}
|
||||||
|
if name is not None and name.strip():
|
||||||
|
patch["name"] = name.strip()[:80]
|
||||||
|
if avatar is not None and avatar.strip():
|
||||||
|
patch["avatar"] = avatar.strip()[:500]
|
||||||
|
if patch:
|
||||||
|
safe_update("nomad_accounts", user["pb_id"], patch)
|
||||||
|
user = {**user, **patch}
|
||||||
|
return _user_profile(user)
|
||||||
|
|
||||||
|
for _email, user in list(_users.items()):
|
||||||
|
if user.get("id") == uid:
|
||||||
|
if name is not None and name.strip():
|
||||||
|
user["name"] = name.strip()[:80]
|
||||||
|
if avatar is not None and avatar.strip():
|
||||||
|
user["avatar"] = avatar.strip()[:500]
|
||||||
|
_persist_json()
|
||||||
|
return _user_profile(user)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def ensure_user_by_email(email: str, name: str = "") -> dict[str, Any]:
|
def ensure_user_by_email(email: str, name: str = "") -> dict[str, Any]:
|
||||||
"""Create or resume a session for meetup checkout flows (NomadCNA ensure-user)."""
|
"""Create or resume a session for meetup checkout flows (NomadCNA ensure-user)."""
|
||||||
display = (name or email.split("@")[0]).strip() or "游民"
|
display = (name or email.split("@")[0]).strip() or "游民"
|
||||||
|
|||||||
@ -37,8 +37,10 @@ class ZPayProvider:
|
|||||||
name = "zpay"
|
name = "zpay"
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.pid = os.getenv("ZPAY_PID", "2025121809351743")
|
self.pid = (os.getenv("ZPAY_PID") or "").strip()
|
||||||
self.key = os.getenv("ZPAY_KEY", "tpEi7wWIWI2kXiYVTpIG6j7it0mjVW89")
|
self.key = (os.getenv("ZPAY_KEY") or "").strip()
|
||||||
|
if not self.pid or not self.key:
|
||||||
|
raise RuntimeError("ZPAY_PID / ZPAY_KEY 未配置,无法发起支付")
|
||||||
self.submit_url = os.getenv("ZPAY_SUBMIT_URL", "https://zpayz.cn/submit.php")
|
self.submit_url = os.getenv("ZPAY_SUBMIT_URL", "https://zpayz.cn/submit.php")
|
||||||
self.mapi_url = os.getenv("ZPAY_MAPI_URL", "https://zpayz.cn/mapi.php")
|
self.mapi_url = os.getenv("ZPAY_MAPI_URL", "https://zpayz.cn/mapi.php")
|
||||||
self.query_url = os.getenv("ZPAY_QUERY_URL", "https://zpayz.cn/api.php")
|
self.query_url = os.getenv("ZPAY_QUERY_URL", "https://zpayz.cn/api.php")
|
||||||
@ -179,8 +181,10 @@ class XorPayProvider:
|
|||||||
name = "xorpay"
|
name = "xorpay"
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.aid = os.getenv("XORPAY_AID", "8220")
|
self.aid = (os.getenv("XORPAY_AID") or "").strip()
|
||||||
self.secret = os.getenv("XORPAY_SECRET", "afcacd99570945f88de62624aaa3578e")
|
self.secret = (os.getenv("XORPAY_SECRET") or "").strip()
|
||||||
|
if not self.aid or not self.secret:
|
||||||
|
raise RuntimeError("XORPAY_AID / XORPAY_SECRET 未配置,无法发起支付")
|
||||||
self.cashier_url = os.getenv("XORPAY_CASHIER_URL", "https://xorpay.com/api/cashier").rstrip("/")
|
self.cashier_url = os.getenv("XORPAY_CASHIER_URL", "https://xorpay.com/api/cashier").rstrip("/")
|
||||||
self.query_url = os.getenv("XORPAY_QUERY_URL", "https://xorpay.com/api/query2").rstrip("/")
|
self.query_url = os.getenv("XORPAY_QUERY_URL", "https://xorpay.com/api/query2").rstrip("/")
|
||||||
|
|
||||||
|
|||||||
@ -10,8 +10,11 @@ from app.services.payment_providers import XorPayProvider, ZPayProvider, provide
|
|||||||
DEV_AUTO_PAY = os.getenv("DEV_AUTO_PAY", "false").lower() in ("1", "true", "yes")
|
DEV_AUTO_PAY = os.getenv("DEV_AUTO_PAY", "false").lower() in ("1", "true", "yes")
|
||||||
PAYMENT_JOIN_AMOUNT = int(os.getenv("PAYMENT_JOIN_AMOUNT", "10000")) # fen (100 yuan)
|
PAYMENT_JOIN_AMOUNT = int(os.getenv("PAYMENT_JOIN_AMOUNT", "10000")) # fen (100 yuan)
|
||||||
PAYMENT_DEFAULT_AMOUNT = int(os.getenv("PAYMENT_DEFAULT_AMOUNT", "10000"))
|
PAYMENT_DEFAULT_AMOUNT = int(os.getenv("PAYMENT_DEFAULT_AMOUNT", "10000"))
|
||||||
|
PAYMENT_EBOOK_AMOUNT = int(os.getenv("PAYMENT_EBOOK_AMOUNT", "2900")) # fen (29 yuan)
|
||||||
SITE_BASE = os.getenv("SITE_BASE_URL", "https://nomadweb.nomadro.com").rstrip("/")
|
SITE_BASE = os.getenv("SITE_BASE_URL", "https://nomadweb.nomadro.com").rstrip("/")
|
||||||
PAY_NOTIFY_URL = os.getenv("PAY_NOTIFY_URL", f"{SITE_BASE}/api/v1/pay/zpay_notify")
|
PAY_NOTIFY_URL = os.getenv("PAY_NOTIFY_URL", f"{SITE_BASE}/api/v1/pay/zpay_notify")
|
||||||
|
EBOOK_PDF_URL = os.getenv("EBOOK_PDF_URL", "")
|
||||||
|
EBOOK_EPUB_URL = os.getenv("EBOOK_EPUB_URL", "")
|
||||||
|
|
||||||
|
|
||||||
def amount_yuan(amount_fen: int) -> str:
|
def amount_yuan(amount_fen: int) -> str:
|
||||||
@ -34,14 +37,18 @@ async def create_pay_redirect(
|
|||||||
device: str = "pc",
|
device: str = "pc",
|
||||||
user_agent: str = "",
|
user_agent: str = "",
|
||||||
client_ip: str = "",
|
client_ip: str = "",
|
||||||
|
product_name: str = "nomadro会员",
|
||||||
) -> str:
|
) -> str:
|
||||||
if DEV_AUTO_PAY:
|
if DEV_AUTO_PAY:
|
||||||
sep = "&" if "?" in return_url else "?"
|
sep = "&" if "?" in return_url else "?"
|
||||||
return f"{return_url}{sep}order_id={order_id}&status=paid"
|
return f"{return_url}{sep}order_id={order_id}&status=paid"
|
||||||
|
|
||||||
|
try:
|
||||||
pay_provider = provider_for(device, provider or os.getenv("PAYMENT_PROVIDER", "zpay"), user_agent)
|
pay_provider = provider_for(device, provider or os.getenv("PAYMENT_PROVIDER", "zpay"), user_agent)
|
||||||
|
except RuntimeError as exc:
|
||||||
|
raise ValueError(str(exc)) from exc
|
||||||
yuan = amount_yuan(amount)
|
yuan = amount_yuan(amount)
|
||||||
name = "nomadro会员"
|
name = product_name
|
||||||
notify = notify_url_for(pay_provider.name)
|
notify = notify_url_for(pay_provider.name)
|
||||||
|
|
||||||
if isinstance(pay_provider, XorPayProvider):
|
if isinstance(pay_provider, XorPayProvider):
|
||||||
@ -78,16 +85,32 @@ async def create_pay_redirect(
|
|||||||
|
|
||||||
|
|
||||||
async def query_order_paid(order_id: str) -> bool:
|
async def query_order_paid(order_id: str) -> bool:
|
||||||
zpay, xorpay = await ZPayProvider().query_order_status(order_id), await XorPayProvider().query_order_status(order_id)
|
if DEV_AUTO_PAY:
|
||||||
return bool(zpay.get("paid") or xorpay.get("paid"))
|
return True
|
||||||
|
paid = False
|
||||||
|
try:
|
||||||
|
zpay = await ZPayProvider().query_order_status(order_id)
|
||||||
|
paid = bool(zpay.get("paid"))
|
||||||
|
except RuntimeError:
|
||||||
|
pass
|
||||||
|
if paid:
|
||||||
|
return True
|
||||||
|
try:
|
||||||
|
xorpay = await XorPayProvider().query_order_status(order_id)
|
||||||
|
return bool(xorpay.get("paid"))
|
||||||
|
except RuntimeError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def verify_notify(provider: str, data: dict) -> bool:
|
def verify_notify(provider: str, data: dict) -> bool:
|
||||||
if DEV_AUTO_PAY:
|
if DEV_AUTO_PAY:
|
||||||
return True
|
return True
|
||||||
|
try:
|
||||||
if provider == "xorpay":
|
if provider == "xorpay":
|
||||||
return XorPayProvider().verify_notify(data)
|
return XorPayProvider().verify_notify(data)
|
||||||
return ZPayProvider().verify_notify(data)
|
return ZPayProvider().verify_notify(data)
|
||||||
|
except RuntimeError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def parse_notify_order_id(provider: str, data: dict) -> str:
|
def parse_notify_order_id(provider: str, data: dict) -> str:
|
||||||
|
|||||||
@ -590,6 +590,32 @@ def send_message(conv_id: str, user_id: str, body: str) -> dict | None:
|
|||||||
return {**msg, "mine": True}
|
return {**msg, "mine": True}
|
||||||
|
|
||||||
|
|
||||||
|
def grant_ebook(user_id: str) -> None:
|
||||||
|
if not user_id:
|
||||||
|
return
|
||||||
|
if use_pb():
|
||||||
|
existing = safe_first("ebook_entitlements", filter=f"userId={q(user_id)}")
|
||||||
|
if existing:
|
||||||
|
safe_update("ebook_entitlements", existing["id"], {"active": True})
|
||||||
|
else:
|
||||||
|
safe_create("ebook_entitlements", {"userId": user_id, "active": True, "product": "nomad-code"})
|
||||||
|
return
|
||||||
|
_jload()
|
||||||
|
_j.setdefault("ebook_entitlements", {})[user_id] = {"active": True, "product": "nomad-code", "updated_at": _now_iso()}
|
||||||
|
_jsave()
|
||||||
|
|
||||||
|
|
||||||
|
def has_ebook(user_id: str) -> bool:
|
||||||
|
if not user_id:
|
||||||
|
return False
|
||||||
|
if use_pb():
|
||||||
|
row = safe_first("ebook_entitlements", filter=f"userId={q(user_id)} && active=true")
|
||||||
|
return bool(row)
|
||||||
|
_jload()
|
||||||
|
ent = (_j.get("ebook_entitlements") or {}).get(user_id) or {}
|
||||||
|
return bool(ent.get("active"))
|
||||||
|
|
||||||
|
|
||||||
def create_order(user_id: str, pay_type: str, amount: int, dev_auto_pay: bool = False) -> dict:
|
def create_order(user_id: str, pay_type: str, amount: int, dev_auto_pay: bool = False) -> dict:
|
||||||
order_id = f"{user_id}_{pay_type}_order_{int(time.time())}_{secrets.token_hex(2)}"
|
order_id = f"{user_id}_{pay_type}_order_{int(time.time())}_{secrets.token_hex(2)}"
|
||||||
status = "paid" if dev_auto_pay else "pending"
|
status = "paid" if dev_auto_pay else "pending"
|
||||||
@ -607,16 +633,15 @@ def create_order(user_id: str, pay_type: str, amount: int, dev_auto_pay: bool =
|
|||||||
_j.setdefault("orders", {})[order_id] = {**order, "id": order_id, "createdAt": _now_iso()}
|
_j.setdefault("orders", {})[order_id] = {**order, "id": order_id, "createdAt": _now_iso()}
|
||||||
_jsave()
|
_jsave()
|
||||||
if dev_auto_pay:
|
if dev_auto_pay:
|
||||||
ensure_membership(user_id)
|
_fulfill_order(user_id, pay_type)
|
||||||
return {"id": order_id, **order, "createdAt": _now_iso()}
|
return {"id": order_id, **order, "createdAt": _now_iso()}
|
||||||
|
|
||||||
|
|
||||||
def get_order(order_id: str) -> dict | None:
|
def _fulfill_order(user_id: str, pay_type: str) -> None:
|
||||||
if use_pb():
|
if pay_type == "ebook":
|
||||||
row = safe_first("orders", filter=f"orderId={q(order_id)}")
|
grant_ebook(user_id)
|
||||||
return row
|
else:
|
||||||
_jload()
|
ensure_membership(user_id)
|
||||||
return (_j.get("orders") or {}).get(order_id)
|
|
||||||
|
|
||||||
|
|
||||||
def mark_order_paid(order_id: str) -> dict | None:
|
def mark_order_paid(order_id: str) -> dict | None:
|
||||||
@ -625,14 +650,14 @@ def mark_order_paid(order_id: str) -> dict | None:
|
|||||||
if not row:
|
if not row:
|
||||||
return None
|
return None
|
||||||
safe_update("orders", row["id"], {"status": "paid"})
|
safe_update("orders", row["id"], {"status": "paid"})
|
||||||
ensure_membership(row.get("userId", ""))
|
_fulfill_order(row.get("userId", ""), row.get("payType") or "join")
|
||||||
return {**row, "status": "paid"}
|
return {**row, "status": "paid"}
|
||||||
_jload()
|
_jload()
|
||||||
order = (_j.get("orders") or {}).get(order_id)
|
order = (_j.get("orders") or {}).get(order_id)
|
||||||
if not order:
|
if not order:
|
||||||
return None
|
return None
|
||||||
order["status"] = "paid"
|
order["status"] = "paid"
|
||||||
ensure_membership(order["userId"])
|
_fulfill_order(order.get("userId", ""), order.get("payType") or "join")
|
||||||
_jsave()
|
_jsave()
|
||||||
return order
|
return order
|
||||||
|
|
||||||
|
|||||||
@ -221,6 +221,11 @@ COLLECTIONS: dict[str, list[dict[str, Any]]] = {
|
|||||||
number("amount", only_int=True),
|
number("amount", only_int=True),
|
||||||
text("status", max=40),
|
text("status", max=40),
|
||||||
],
|
],
|
||||||
|
"ebook_entitlements": [
|
||||||
|
text("userId", required=True, max=80),
|
||||||
|
boolean("active"),
|
||||||
|
text("product", max=80),
|
||||||
|
],
|
||||||
"feedback": [
|
"feedback": [
|
||||||
text("type", max=40),
|
text("type", max=40),
|
||||||
text("email", max=255),
|
text("email", max=255),
|
||||||
|
|||||||
@ -3,7 +3,10 @@ DEV_AUTO_PAY=false
|
|||||||
PAYMENT_PROVIDER=zpay
|
PAYMENT_PROVIDER=zpay
|
||||||
PAYMENT_JOIN_AMOUNT=10000
|
PAYMENT_JOIN_AMOUNT=10000
|
||||||
PAYMENT_DEFAULT_AMOUNT=10000
|
PAYMENT_DEFAULT_AMOUNT=10000
|
||||||
|
PAYMENT_EBOOK_AMOUNT=2900
|
||||||
SITE_BASE_URL=https://nomadweb.nomadro.com
|
SITE_BASE_URL=https://nomadweb.nomadro.com
|
||||||
|
EBOOK_PDF_URL=
|
||||||
|
EBOOK_EPUB_URL=
|
||||||
|
|
||||||
# PocketBase (host process :8090)
|
# PocketBase (host process :8090)
|
||||||
POCKETBASE_URL=http://127.0.0.1:8090
|
POCKETBASE_URL=http://127.0.0.1:8090
|
||||||
|
|||||||
@ -30,10 +30,10 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"checkout": {
|
"checkout": {
|
||||||
"enabled": false,
|
"enabled": true,
|
||||||
"provider": "creem",
|
"provider": "zpay",
|
||||||
"buttonText": "Buy Now — $29",
|
"buttonText": "购买下载版 ¥29",
|
||||||
"productName": "Download Edition (PDF & EPUB)"
|
"productName": "游牧代码 · 下载版 (PDF & EPUB)"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
108
frontend/src/app/book/thanks/page.tsx
Normal file
108
frontend/src/app/book/thanks/page.tsx
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Suspense, useEffect, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
|
import SiteShell from "@/components/SiteShell";
|
||||||
|
import { useAuth } from "@/lib/auth";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
|
||||||
|
function BookThanksInner() {
|
||||||
|
const { token } = useAuth();
|
||||||
|
const router = useRouter();
|
||||||
|
const params = useSearchParams();
|
||||||
|
const [status, setStatus] = useState<"checking" | "ok" | "pending" | "need-login">("checking");
|
||||||
|
const [files, setFiles] = useState<{ format: string; label: string; url: string }[]>([]);
|
||||||
|
const [msg, setMsg] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token) {
|
||||||
|
setStatus("need-login");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const orderId = params.get("order_id") || localStorage.getItem("nomadro-ebook-order") || "";
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
if (orderId) {
|
||||||
|
await api.completePayment(token, orderId).catch(() => null);
|
||||||
|
}
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
if (cancelled) return;
|
||||||
|
const st = await api.ebookStatus(token);
|
||||||
|
if (st.owned) {
|
||||||
|
const dl = await api.ebookDownloads(token);
|
||||||
|
if (cancelled) return;
|
||||||
|
setFiles(dl.files || []);
|
||||||
|
setStatus("ok");
|
||||||
|
localStorage.removeItem("nomadro-ebook-order");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await new Promise((r) => setTimeout(r, 1000));
|
||||||
|
}
|
||||||
|
if (!cancelled) {
|
||||||
|
setStatus("pending");
|
||||||
|
setMsg("支付确认中,请稍后刷新;或联系支持核对订单。");
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (!cancelled) {
|
||||||
|
setStatus("pending");
|
||||||
|
setMsg(e instanceof Error ? e.message : "无法确认购买状态");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [token, params]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container" style={{ padding: "48px 24px", maxWidth: 560 }}>
|
||||||
|
<nav className="detail-nav">
|
||||||
|
<Link href="/book">← 电子书</Link>
|
||||||
|
</nav>
|
||||||
|
<h1>下载版</h1>
|
||||||
|
{status === "need-login" && (
|
||||||
|
<p>
|
||||||
|
请先 <Link href="/login?next=/book/thanks">登录</Link> 查看下载。
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{status === "checking" && <p>正在确认订单…</p>}
|
||||||
|
{status === "pending" && (
|
||||||
|
<div>
|
||||||
|
<p>{msg}</p>
|
||||||
|
<button type="button" className="btn btn-primary" onClick={() => router.refresh()}>
|
||||||
|
刷新状态
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{status === "ok" && (
|
||||||
|
<div>
|
||||||
|
<p>购买成功,可下载:</p>
|
||||||
|
<ul className="ebook-download-list">
|
||||||
|
{files.map((f) => (
|
||||||
|
<li key={f.format}>
|
||||||
|
<a href={f.url} className="btn btn-primary btn-sm" target="_blank" rel="noreferrer">
|
||||||
|
{f.label}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<p style={{ marginTop: 16 }}>
|
||||||
|
<Link href="/book/read">继续在线阅读 →</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BookThanksPage() {
|
||||||
|
return (
|
||||||
|
<SiteShell>
|
||||||
|
<Suspense fallback={<div className="container" style={{ padding: 48 }}>确认中…</div>}>
|
||||||
|
<BookThanksInner />
|
||||||
|
</Suspense>
|
||||||
|
</SiteShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -2682,6 +2682,35 @@ img { max-width: 100%; display: block; }
|
|||||||
border: var(--border-glass);
|
border: var(--border-glass);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.profile-avatar-wrap {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-avatar-img {
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
border: var(--border-glass);
|
||||||
|
background: var(--bg-glass);
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-avatar-upload {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--accent-3);
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-avatar-upload:hover {
|
||||||
|
opacity: 1;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
.profile-info { flex: 1; }
|
.profile-info { flex: 1; }
|
||||||
.profile-info h1 {
|
.profile-info h1 {
|
||||||
font-family: var(--font-display);
|
font-family: var(--font-display);
|
||||||
|
|||||||
@ -34,7 +34,7 @@ function getBadges(stats: ProfileStats | null, trip: TripItem[], favCount: numbe
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function ProfilePage() {
|
export default function ProfilePage() {
|
||||||
const { user, token, favorites, logout, planSyncStatus } = useAuth();
|
const { user, token, favorites, logout, planSyncStatus, refreshUser } = useAuth();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const [stats, setStats] = useState<ProfileStats | null>(null);
|
const [stats, setStats] = useState<ProfileStats | null>(null);
|
||||||
@ -42,6 +42,28 @@ export default function ProfilePage() {
|
|||||||
const [trip, setTrip] = useState<TripItem[]>([]);
|
const [trip, setTrip] = useState<TripItem[]>([]);
|
||||||
const [meta, setMeta] = useState<PlanMeta | null>(null);
|
const [meta, setMeta] = useState<PlanMeta | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [uploadingAvatar, setUploadingAvatar] = useState(false);
|
||||||
|
|
||||||
|
const onAvatarChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
e.target.value = "";
|
||||||
|
if (!file || !token) return;
|
||||||
|
if (file.size > 5 * 1024 * 1024) {
|
||||||
|
toast("图片请小于 5MB", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setUploadingAvatar(true);
|
||||||
|
try {
|
||||||
|
const uploaded = await api.uploadMedia(token, file, "avatar");
|
||||||
|
await api.updateProfile(token, { avatar: uploaded.url });
|
||||||
|
await refreshUser?.();
|
||||||
|
toast("头像已更新", "success");
|
||||||
|
} catch (err) {
|
||||||
|
toast(err instanceof Error ? err.message : "上传失败", "error");
|
||||||
|
} finally {
|
||||||
|
setUploadingAvatar(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const refreshPlan = () => {
|
const refreshPlan = () => {
|
||||||
setTrip(loadTrip<TripItem>());
|
setTrip(loadTrip<TripItem>());
|
||||||
@ -98,7 +120,24 @@ export default function ProfilePage() {
|
|||||||
<SiteShell>
|
<SiteShell>
|
||||||
<div className="profile-page">
|
<div className="profile-page">
|
||||||
<div className="profile-header">
|
<div className="profile-header">
|
||||||
<div className="profile-avatar">{user.avatar}</div>
|
<div className="profile-avatar-wrap">
|
||||||
|
{user.avatar?.startsWith("http") ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img src={user.avatar} alt="" className="profile-avatar-img" />
|
||||||
|
) : (
|
||||||
|
<div className="profile-avatar">{user.avatar || "🧑💻"}</div>
|
||||||
|
)}
|
||||||
|
<label className="profile-avatar-upload">
|
||||||
|
{uploadingAvatar ? "上传中…" : "更换头像"}
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/jpeg,image/png,image/webp,image/gif"
|
||||||
|
hidden
|
||||||
|
disabled={uploadingAvatar}
|
||||||
|
onChange={onAvatarChange}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
<div className="profile-info">
|
<div className="profile-info">
|
||||||
<h1>{user.name}</h1>
|
<h1>{user.name}</h1>
|
||||||
<p>{user.email}</p>
|
<p>{user.email}</p>
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { ShareToX } from './ShareToX';
|
import { ShareToX } from './ShareToX';
|
||||||
|
import { BuyEbook } from './BuyEbook';
|
||||||
import { getReadingProgress } from '@/lib/ebook/reading-progress';
|
import { getReadingProgress } from '@/lib/ebook/reading-progress';
|
||||||
import { getBookShareText, getReadShareUrl } from '@/lib/ebook/share-text';
|
import { getBookShareText, getReadShareUrl } from '@/lib/ebook/share-text';
|
||||||
import { trackEvent } from '@/lib/ebook/analytics';
|
import { trackEvent } from '@/lib/ebook/analytics';
|
||||||
@ -35,6 +36,7 @@ export function BookEnd({ book }: { book: Book }) {
|
|||||||
? t('cover.shareDone', { tagline: book.tagline })
|
? t('cover.shareDone', { tagline: book.tagline })
|
||||||
: t('cover.flipBack', { read: readCount, total: book.chapters.length })}
|
: t('cover.flipBack', { read: readCount, total: book.chapters.length })}
|
||||||
</p>
|
</p>
|
||||||
|
<BuyEbook compact />
|
||||||
<div className="book-end-actions">
|
<div className="book-end-actions">
|
||||||
<ShareToX text={shareText} url={getReadShareUrl()} label={t('reader.shareThoughts')} />
|
<ShareToX text={shareText} url={getReadShareUrl()} label={t('reader.shareThoughts')} />
|
||||||
<button
|
<button
|
||||||
|
|||||||
@ -1,50 +1,79 @@
|
|||||||
'use client';
|
"use client";
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useEffect, useState } from "react";
|
||||||
import { trackEvent } from '@/lib/ebook/analytics';
|
import Link from "next/link";
|
||||||
import { getFeatures } from '@/lib/ebook/features';
|
import { useRouter } from "next/navigation";
|
||||||
import { useT } from '@/components/ebook/LocaleProvider';
|
import { trackEvent } from "@/lib/ebook/analytics";
|
||||||
|
import { getFeatures } from "@/lib/ebook/features";
|
||||||
|
import { useT } from "@/components/ebook/LocaleProvider";
|
||||||
|
import { useAuth } from "@/lib/auth";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
|
||||||
|
/** Buy download edition via FastAPI /pay/create (pay_type=ebook). */
|
||||||
export function BuyEbook({ compact = false }: { compact?: boolean }) {
|
export function BuyEbook({ compact = false }: { compact?: boolean }) {
|
||||||
const t = useT();
|
const t = useT();
|
||||||
|
const { token } = useAuth();
|
||||||
|
const router = useRouter();
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState("");
|
||||||
const productName = getFeatures().payment.checkout.productName;
|
const [owned, setOwned] = useState(false);
|
||||||
const buttonText = getFeatures().payment.checkout.buttonText;
|
const features = getFeatures();
|
||||||
|
const enabled = features.payment?.checkout?.enabled !== false;
|
||||||
|
const productName = features.payment?.checkout?.productName || "Download Edition";
|
||||||
|
const buttonText = features.payment?.checkout?.buttonText || "购买下载版";
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token) return;
|
||||||
|
api.ebookStatus(token).then((s) => setOwned(s.owned)).catch(() => {});
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
|
if (!enabled) return null;
|
||||||
|
|
||||||
const buy = async () => {
|
const buy = async () => {
|
||||||
setError('');
|
setError("");
|
||||||
setBusy(true);
|
if (!token) {
|
||||||
trackEvent('buy_click', { from: compact ? 'reader' : 'cover' });
|
router.push("/login?next=/book");
|
||||||
try {
|
return;
|
||||||
const res = await fetch('/api/creem/checkout', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
});
|
|
||||||
const data = (await res.json()) as { ok?: boolean; checkoutUrl?: string; error?: string };
|
|
||||||
if (!res.ok || !data.checkoutUrl) {
|
|
||||||
throw new Error(data.error || t('payment.checkoutError'));
|
|
||||||
}
|
}
|
||||||
window.location.href = data.checkoutUrl;
|
if (owned) {
|
||||||
|
router.push("/book/thanks");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusy(true);
|
||||||
|
trackEvent("buy_click", { from: compact ? "reader" : "cover" });
|
||||||
|
try {
|
||||||
|
const returnUrl = `${window.location.origin}/book/thanks`;
|
||||||
|
const res = await api.createPayment(token, returnUrl, "ebook");
|
||||||
|
if (res.order_id) {
|
||||||
|
localStorage.setItem("nomadro-ebook-order", res.order_id);
|
||||||
|
}
|
||||||
|
// DEV_AUTO_PAY or already-paid: confirm then go to thanks
|
||||||
|
if (res.status === "paid" && res.order_id) {
|
||||||
|
await api.completePayment(token, res.order_id).catch(() => null);
|
||||||
|
router.push(`/book/thanks?order_id=${encodeURIComponent(res.order_id)}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!res.redirect_url) {
|
||||||
|
throw new Error(t("payment.checkoutFailed"));
|
||||||
|
}
|
||||||
|
window.location.href = res.redirect_url;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : t('payment.checkoutFailed'));
|
setError(err instanceof Error ? err.message : t("payment.checkoutFailed"));
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className={`buy-ebook ${compact ? 'is-compact' : ''}`}>
|
<section className={`buy-ebook${compact ? " is-compact" : ""}`}>
|
||||||
<h2 className="buy-ebook-title">{t('payment.buyGet', { name: productName })}</h2>
|
<h2 className="buy-ebook-title">{t("payment.buyGet", { name: productName })}</h2>
|
||||||
<p className="buy-ebook-sub">{t('payment.buySub')}</p>
|
<p className="buy-ebook-sub">{t("payment.buySub")}</p>
|
||||||
<button
|
<button type="button" className="buy-ebook-btn" disabled={busy} onClick={buy}>
|
||||||
type="button"
|
{busy ? t("payment.openingCheckout") : owned ? "已购买 · 去下载" : buttonText}
|
||||||
className="buy-ebook-btn"
|
|
||||||
disabled={busy}
|
|
||||||
onClick={buy}
|
|
||||||
>
|
|
||||||
{busy ? t('payment.openingCheckout') : buttonText}
|
|
||||||
</button>
|
</button>
|
||||||
{error ? <p className="buy-ebook-error">{error}</p> : null}
|
{error ? <p className="buy-ebook-error">{error}</p> : null}
|
||||||
|
<p className="buy-ebook-hint">
|
||||||
|
也可免费 <Link href="/book/read">在线阅读</Link>
|
||||||
|
</p>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import { ScrollReveal } from "./ScrollReveal";
|
|||||||
import { SkillRadar } from "./SkillRadar";
|
import { SkillRadar } from "./SkillRadar";
|
||||||
import { CoverChapterGrid } from "./CoverChapterGrid";
|
import { CoverChapterGrid } from "./CoverChapterGrid";
|
||||||
import { CoverStickyCta, SiteFooter } from "./GrowthKit";
|
import { CoverStickyCta, SiteFooter } from "./GrowthKit";
|
||||||
|
import { BuyEbook } from "./BuyEbook";
|
||||||
import { AdSlot } from "./AdSlot";
|
import { AdSlot } from "./AdSlot";
|
||||||
import { getBookShareText, getBookShareUrl } from "@/lib/ebook/share-text";
|
import { getBookShareText, getBookShareUrl } from "@/lib/ebook/share-text";
|
||||||
import { trackEvent } from "@/lib/ebook/analytics";
|
import { trackEvent } from "@/lib/ebook/analytics";
|
||||||
@ -141,6 +142,10 @@ export function CoverPage({
|
|||||||
</ScrollReveal>
|
</ScrollReveal>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<ScrollReveal delay={320}>
|
||||||
|
<BuyEbook />
|
||||||
|
</ScrollReveal>
|
||||||
|
|
||||||
<SiteFooter
|
<SiteFooter
|
||||||
bookTitle={book.title}
|
bookTitle={book.title}
|
||||||
contactEmail={site.brand.contactEmail}
|
contactEmail={site.brand.contactEmail}
|
||||||
|
|||||||
@ -137,6 +137,39 @@ export const api = {
|
|||||||
register: (email: string, password: string, name: string) =>
|
register: (email: string, password: string, name: string) =>
|
||||||
fetchAPI<AuthResponse>("/auth/register", { method: "POST", body: JSON.stringify({ email, password, name }) }),
|
fetchAPI<AuthResponse>("/auth/register", { method: "POST", body: JSON.stringify({ email, password, name }) }),
|
||||||
demoLogin: () => fetchAPI<AuthResponse>("/auth/demo"),
|
demoLogin: () => fetchAPI<AuthResponse>("/auth/demo"),
|
||||||
|
updateProfile: (token: string, body: { name?: string; avatar?: string }) =>
|
||||||
|
fetchAPI<{ id: string; email: string; name: string; avatar: string }>("/auth/me", {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}),
|
||||||
|
getMe: (token: string) =>
|
||||||
|
fetchAPI<{ id: string; email: string; name: string; avatar: string }>("/auth/me", {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
}),
|
||||||
|
uploadMedia: async (token: string, file: File, purpose = "avatar") => {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append("file", file);
|
||||||
|
form.append("purpose", purpose);
|
||||||
|
const res = await fetch(`${API_BASE}/upload-media?purpose=${encodeURIComponent(purpose)}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
body: form,
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({}));
|
||||||
|
throw new Error((err as { detail?: string }).detail || "上传失败");
|
||||||
|
}
|
||||||
|
return res.json() as Promise<{ success: boolean; url: string; object_key: string }>;
|
||||||
|
},
|
||||||
|
ebookStatus: (token: string) =>
|
||||||
|
fetchAPI<{ owned: boolean; amount_fen: number; amount_yuan: string }>("/ebook/status", {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
}),
|
||||||
|
ebookDownloads: (token: string) =>
|
||||||
|
fetchAPI<{ owned: boolean; files: { format: string; label: string; url: string }[] }>("/ebook/downloads", {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
}),
|
||||||
getFavorites: (token: string) =>
|
getFavorites: (token: string) =>
|
||||||
fetchAPI<{ slugs: string[] }>("/auth/favorites", { headers: { Authorization: `Bearer ${token}` } }),
|
fetchAPI<{ slugs: string[] }>("/auth/favorites", { headers: { Authorization: `Bearer ${token}` } }),
|
||||||
toggleFavorite: (slug: string, token: string) =>
|
toggleFavorite: (slug: string, token: string) =>
|
||||||
@ -236,7 +269,7 @@ export const api = {
|
|||||||
body: JSON.stringify({ pay_type: payType, return_url: returnUrl }),
|
body: JSON.stringify({ pay_type: payType, return_url: returnUrl }),
|
||||||
}),
|
}),
|
||||||
completePayment: (token: string, orderId: string) =>
|
completePayment: (token: string, orderId: string) =>
|
||||||
fetchAPI<{ success: boolean; vip: boolean }>(`/pay/complete?order_id=${encodeURIComponent(orderId)}`, {
|
fetchAPI<{ success: boolean; vip: boolean; ebook?: boolean }>(`/pay/complete?order_id=${encodeURIComponent(orderId)}`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { Authorization: `Bearer ${token}` },
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
}),
|
}),
|
||||||
|
|||||||
@ -14,6 +14,7 @@ interface AuthCtx {
|
|||||||
register: (email: string, password: string, name: string) => Promise<boolean>;
|
register: (email: string, password: string, name: string) => Promise<boolean>;
|
||||||
demoLogin: () => Promise<boolean>;
|
demoLogin: () => Promise<boolean>;
|
||||||
logout: () => void;
|
logout: () => void;
|
||||||
|
refreshUser: () => Promise<void>;
|
||||||
toggleFavorite: (slug: string) => Promise<void>;
|
toggleFavorite: (slug: string) => Promise<void>;
|
||||||
isFavorite: (slug: string) => boolean;
|
isFavorite: (slug: string) => boolean;
|
||||||
}
|
}
|
||||||
@ -96,6 +97,21 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
localStorage.removeItem("nomad-user");
|
localStorage.removeItem("nomad-user");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const refreshUser = async () => {
|
||||||
|
const t = token || localStorage.getItem("nomad-token");
|
||||||
|
if (!t) return;
|
||||||
|
try {
|
||||||
|
const me = await api.getMe(t);
|
||||||
|
setUser(me as UserProfile);
|
||||||
|
localStorage.setItem("nomad-user", JSON.stringify(me));
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem("nomad-user");
|
||||||
|
if (saved) setUser(JSON.parse(saved));
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const toggleFavorite = async (slug: string) => {
|
const toggleFavorite = async (slug: string) => {
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
try {
|
try {
|
||||||
@ -106,7 +122,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthContext.Provider value={{
|
<AuthContext.Provider value={{
|
||||||
user, token, favorites, planSyncStatus, login, register, demoLogin, logout,
|
user, token, favorites, planSyncStatus, login, register, demoLogin, logout, refreshUser,
|
||||||
toggleFavorite, isFavorite: (slug) => favorites.includes(slug),
|
toggleFavorite, isFavorite: (slug) => favorites.includes(slug),
|
||||||
}}>
|
}}>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@ -23,10 +23,10 @@ const fallback: FeatureConfig = {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
checkout: {
|
checkout: {
|
||||||
enabled: false,
|
enabled: true,
|
||||||
provider: "creem",
|
provider: "zpay",
|
||||||
buttonText: "Buy Now - $29",
|
buttonText: "购买下载版 ¥29",
|
||||||
productName: "Download Edition (PDF & EPUB)",
|
productName: "游牧代码 · 下载版 (PDF & EPUB)",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
feedback: {
|
feedback: {
|
||||||
|
|||||||
@ -89,10 +89,10 @@ const fallback: FeatureConfig = {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
checkout: {
|
checkout: {
|
||||||
enabled: false,
|
enabled: true,
|
||||||
provider: 'creem',
|
provider: 'zpay',
|
||||||
buttonText: 'Buy Now - $29',
|
buttonText: '购买下载版 ¥29',
|
||||||
productName: 'Download Edition (PDF & EPUB)',
|
productName: '游牧代码 · 下载版 (PDF & EPUB)',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
feedback: {
|
feedback: {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user