diff --git a/backend/app/main.py b/backend/app/main.py index 7802bd0..1f09dd9 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -2,7 +2,7 @@ from fastapi.middleware.cors import CORSMiddleware 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( 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(community.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(media.router, prefix="/api/v1") diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index 3dca497..483c22c 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -1,4 +1,5 @@ from fastapi import APIRouter, Header, HTTPException +from pydantic import BaseModel import httpx @@ -10,6 +11,7 @@ from app.schemas import ( from app.services.auth import ( get_favorites, get_plan, get_user_by_token, login_demo, login_google, login_user, register_user, save_plan, toggle_favorite, + update_profile, ) from app.services.pocketbase import pb @@ -41,6 +43,26 @@ async def me(authorization: str | None = Header(None)): 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) async def demo_login(): """一键体验演示账号""" diff --git a/backend/app/routers/ebook.py b/backend/app/routers/ebook.py new file mode 100644 index 0000000..b8ad957 --- /dev/null +++ b/backend/app/routers/ebook.py @@ -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} diff --git a/backend/app/routers/payment.py b/backend/app/routers/payment.py index 95faaaa..5cf834a 100644 --- a/backend/app/routers/payment.py +++ b/backend/app/routers/payment.py @@ -9,6 +9,7 @@ from app.services import social_store from app.services.payment_service import ( DEV_AUTO_PAY, PAYMENT_DEFAULT_AMOUNT, + PAYMENT_EBOOK_AMOUNT, PAYMENT_JOIN_AMOUNT, create_pay_redirect, parse_notify_order_id, @@ -44,21 +45,33 @@ async def create_payment( authorization: str | None = Header(None), ): 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) return_url = body.return_url or "https://nomadweb.nomadro.com/join/paid" ua = request.headers.get("user-agent", "") device = "wechat" if "micromessenger" in ua.lower() else "pc" - redirect = await create_pay_redirect( - order["id"], - amount, - return_url, - body.provider or "", - channel="wxpay" if device == "wechat" else "alipay", - device=device, - user_agent=ua, - client_ip=_client_ip(request), - ) + try: + redirect = await create_pay_redirect( + order["id"], + amount, + return_url, + body.provider or "", + channel="wxpay" if device == "wechat" else "alipay", + device=device, + user_agent=ua, + client_ip=_client_ip(request), + product_name=product_name, + ) + except ValueError as exc: + raise HTTPException(503, str(exc)) from exc return PayCreateResponse( order_id=order["id"], amount=amount, @@ -100,7 +113,7 @@ async def complete_order( social_store.mark_order_paid(order_id) else: 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: diff --git a/backend/app/services/auth.py b/backend/app/services/auth.py index 0db40e9..8530249 100644 --- a/backend/app/services/auth.py +++ b/backend/app/services/auth.py @@ -341,6 +341,35 @@ def save_plan(token: str, items: list, meta: dict, updated_at: int | None = None 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]: """Create or resume a session for meetup checkout flows (NomadCNA ensure-user).""" display = (name or email.split("@")[0]).strip() or "游民" diff --git a/backend/app/services/payment_providers.py b/backend/app/services/payment_providers.py index 50459ef..184cb05 100644 --- a/backend/app/services/payment_providers.py +++ b/backend/app/services/payment_providers.py @@ -37,8 +37,10 @@ class ZPayProvider: name = "zpay" def __init__(self) -> None: - self.pid = os.getenv("ZPAY_PID", "2025121809351743") - self.key = os.getenv("ZPAY_KEY", "tpEi7wWIWI2kXiYVTpIG6j7it0mjVW89") + self.pid = (os.getenv("ZPAY_PID") or "").strip() + 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.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") @@ -179,8 +181,10 @@ class XorPayProvider: name = "xorpay" def __init__(self) -> None: - self.aid = os.getenv("XORPAY_AID", "8220") - self.secret = os.getenv("XORPAY_SECRET", "afcacd99570945f88de62624aaa3578e") + self.aid = (os.getenv("XORPAY_AID") or "").strip() + 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.query_url = os.getenv("XORPAY_QUERY_URL", "https://xorpay.com/api/query2").rstrip("/") diff --git a/backend/app/services/payment_service.py b/backend/app/services/payment_service.py index 288aba2..5be95ba 100644 --- a/backend/app/services/payment_service.py +++ b/backend/app/services/payment_service.py @@ -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") PAYMENT_JOIN_AMOUNT = int(os.getenv("PAYMENT_JOIN_AMOUNT", "10000")) # fen (100 yuan) 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("/") 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: @@ -34,14 +37,18 @@ async def create_pay_redirect( device: str = "pc", user_agent: str = "", client_ip: str = "", + product_name: str = "nomadro会员", ) -> str: if DEV_AUTO_PAY: sep = "&" if "?" in return_url else "?" return f"{return_url}{sep}order_id={order_id}&status=paid" - pay_provider = provider_for(device, provider or os.getenv("PAYMENT_PROVIDER", "zpay"), user_agent) + try: + 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) - name = "nomadro会员" + name = product_name notify = notify_url_for(pay_provider.name) if isinstance(pay_provider, XorPayProvider): @@ -78,16 +85,32 @@ async def create_pay_redirect( 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) - return bool(zpay.get("paid") or xorpay.get("paid")) + if DEV_AUTO_PAY: + 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: if DEV_AUTO_PAY: return True - if provider == "xorpay": - return XorPayProvider().verify_notify(data) - return ZPayProvider().verify_notify(data) + try: + if provider == "xorpay": + return XorPayProvider().verify_notify(data) + return ZPayProvider().verify_notify(data) + except RuntimeError: + return False def parse_notify_order_id(provider: str, data: dict) -> str: diff --git a/backend/app/services/social_store.py b/backend/app/services/social_store.py index ea55e46..8938aac 100644 --- a/backend/app/services/social_store.py +++ b/backend/app/services/social_store.py @@ -590,6 +590,32 @@ def send_message(conv_id: str, user_id: str, body: str) -> dict | None: 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: order_id = f"{user_id}_{pay_type}_order_{int(time.time())}_{secrets.token_hex(2)}" 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()} _jsave() if dev_auto_pay: - ensure_membership(user_id) + _fulfill_order(user_id, pay_type) return {"id": order_id, **order, "createdAt": _now_iso()} -def get_order(order_id: str) -> dict | None: - if use_pb(): - row = safe_first("orders", filter=f"orderId={q(order_id)}") - return row - _jload() - return (_j.get("orders") or {}).get(order_id) +def _fulfill_order(user_id: str, pay_type: str) -> None: + if pay_type == "ebook": + grant_ebook(user_id) + else: + ensure_membership(user_id) 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: return None 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"} _jload() order = (_j.get("orders") or {}).get(order_id) if not order: return None order["status"] = "paid" - ensure_membership(order["userId"]) + _fulfill_order(order.get("userId", ""), order.get("payType") or "join") _jsave() return order diff --git a/backend/scripts/init_pb_collections.py b/backend/scripts/init_pb_collections.py index 16ca6bf..46c4402 100644 --- a/backend/scripts/init_pb_collections.py +++ b/backend/scripts/init_pb_collections.py @@ -221,6 +221,11 @@ COLLECTIONS: dict[str, list[dict[str, Any]]] = { number("amount", only_int=True), text("status", max=40), ], + "ebook_entitlements": [ + text("userId", required=True, max=80), + boolean("active"), + text("product", max=80), + ], "feedback": [ text("type", max=40), text("email", max=255), diff --git a/deploy/nomadro-api.env.example b/deploy/nomadro-api.env.example index 0488ab6..a4f79c9 100644 --- a/deploy/nomadro-api.env.example +++ b/deploy/nomadro-api.env.example @@ -3,7 +3,10 @@ DEV_AUTO_PAY=false PAYMENT_PROVIDER=zpay PAYMENT_JOIN_AMOUNT=10000 PAYMENT_DEFAULT_AMOUNT=10000 +PAYMENT_EBOOK_AMOUNT=2900 SITE_BASE_URL=https://nomadweb.nomadro.com +EBOOK_PDF_URL= +EBOOK_EPUB_URL= # PocketBase (host process :8090) POCKETBASE_URL=http://127.0.0.1:8090 diff --git a/frontend/content/book/features.json b/frontend/content/book/features.json index 7211bb3..3586fad 100644 --- a/frontend/content/book/features.json +++ b/frontend/content/book/features.json @@ -30,10 +30,10 @@ ] }, "checkout": { - "enabled": false, - "provider": "creem", - "buttonText": "Buy Now — $29", - "productName": "Download Edition (PDF & EPUB)" + "enabled": true, + "provider": "zpay", + "buttonText": "购买下载版 ¥29", + "productName": "游牧代码 · 下载版 (PDF & EPUB)" } }, diff --git a/frontend/src/app/book/thanks/page.tsx b/frontend/src/app/book/thanks/page.tsx new file mode 100644 index 0000000..a7c83e7 --- /dev/null +++ b/frontend/src/app/book/thanks/page.tsx @@ -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 ( +
+ 请先 登录 查看下载。 +
+ )} + {status === "checking" &&正在确认订单…
} + {status === "pending" && ( +{msg}
+ +{user.email}
diff --git a/frontend/src/components/ebook/BookEnd.tsx b/frontend/src/components/ebook/BookEnd.tsx index f4415cf..da838f9 100644 --- a/frontend/src/components/ebook/BookEnd.tsx +++ b/frontend/src/components/ebook/BookEnd.tsx @@ -3,6 +3,7 @@ import Link from 'next/link'; import { useEffect, useState } from 'react'; import { ShareToX } from './ShareToX'; +import { BuyEbook } from './BuyEbook'; import { getReadingProgress } from '@/lib/ebook/reading-progress'; import { getBookShareText, getReadShareUrl } from '@/lib/ebook/share-text'; import { trackEvent } from '@/lib/ebook/analytics'; @@ -35,6 +36,7 @@ export function BookEnd({ book }: { book: Book }) { ? t('cover.shareDone', { tagline: book.tagline }) : t('cover.flipBack', { read: readCount, total: book.chapters.length })} +