""" salon API 主入口 支付(ZPAY/XorPay)+ 报名、用户、小红书资料 路由: - /salon/* 支付(payh5、payh5/redirect、order_status、zpay_notify、xorpay_notify) - /api/salon/* 报名、check-user、ensure-user、complete-order、join、volunteers、xiaohongshu/profile 本地调试:BASE_URL 需为 ZPAY 可访问地址(如 ngrok) 线上部署:BASE_URL=https://salonapi.hackrobot.cn(必须为 ZPAY 可公网访问的地址) """ import logging import sys import requests from fastapi import FastAPI, Request, HTTPException from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse from .config import BASE_URL from .routers import salon_router, salon_meetup_router app = FastAPI( title="Salon API", description="沙龙报名、支付、小红书资料", version="2.0", ) app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_credentials=False, allow_methods=["*"], allow_headers=["*"], ) class _SafeStderrProxy: def write(self, message: str) -> int: try: return sys.stderr.write(message) except (OSError, ValueError): return 0 def flush(self) -> None: try: sys.stderr.flush() except (OSError, ValueError): pass def _safe_log(level: int, message: str, *args, **kwargs) -> None: try: logging.log(level, message, *args, **kwargs) except Exception: pass @app.exception_handler(Exception) async def global_exception_handler(request: Request, exc: Exception): """捕获未处理异常,记录日志后返回 500(HTTPException 交由 FastAPI 默认处理)""" if isinstance(exc, HTTPException): raise exc _safe_log(logging.ERROR, "未处理异常: %s - %s", request.url.path, exc, exc_info=True) return JSONResponse(status_code=500, content={"detail": str(exc)}) logging.raiseExceptions = False logging.basicConfig( level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", handlers=[logging.StreamHandler(_SafeStderrProxy())], force=True, ) def _safe_stderr_write(message: str) -> None: try: sys.stderr.write(message) sys.stderr.flush() except (OSError, ValueError): pass def _should_trace_request(path: str) -> bool: return "/salon" in path or "/api/salon" in path # 启动时校验 BASE_URL(线上部署必须为公网可访问地址,否则 ZPAY 无法回调) def _check_base_url(): url = (BASE_URL or "").strip().lower() if not url: logging.warning("BASE_URL 未设置,将使用默认值。线上部署请务必设置 BASE_URL 为公网地址") return if "localhost" in url or "127.0.0.1" in url or url.startswith("http://192.168.") or url.startswith("http://10."): logging.warning( "BASE_URL=%s 疑似内网地址,ZPAY 无法回调!线上部署请设置 BASE_URL 为公网地址,如 https://salonapi.hackrobot.cn", BASE_URL, ) @app.on_event("startup") async def startup_event(): _check_base_url() @app.get("/health") async def health(check_zpay: str = ""): """健康检查。check_zpay=1 时额外检测 zpayz.cn 连通性(含 SSL、mapi 接口)""" out = {"status": "ok", "service": "salonapi", "base_url": BASE_URL} if str(check_zpay).lower() in ("1", "true", "yes"): # 1) 检测 zpayz.cn 首页 try: r = requests.get("https://zpayz.cn", timeout=5) out["zpayz_reachable"] = r.status_code in (200, 301, 302, 404) except requests.exceptions.SSLError as e: out["zpayz_reachable"] = False out["zpayz_error"] = f"SSL 错误: {e}(Ubuntu 请执行: apt install ca-certificates)" except requests.RequestException as e: out["zpayz_reachable"] = False out["zpayz_error"] = str(e) # 2) 检测 mapi 接口(实际支付调用地址) if out.get("zpayz_reachable"): try: r2 = requests.post("https://zpayz.cn/mapi.php", data={"pid": "test"}, timeout=5) out["zpayz_mapi_reachable"] = True out["zpayz_mapi_status"] = r2.status_code except requests.exceptions.SSLError as e: out["zpayz_mapi_reachable"] = False out["zpayz_mapi_error"] = f"SSL: {e}" except requests.RequestException as e: out["zpayz_mapi_reachable"] = False out["zpayz_mapi_error"] = str(e) return out # 请求日志中间件:每个请求都打印到 stderr,确保终端可见 @app.middleware("http") async def log_requests(request, call_next): path = request.url.path if _should_trace_request(path): _safe_stderr_write(f"[salonapi] >>> {request.method} {path}\n") resp = await call_next(request) if _should_trace_request(path): _safe_stderr_write(f"[salonapi] <<< {request.method} {path} {resp.status_code}\n") return resp # 挂载路由 app.include_router(salon_router) app.include_router(salon_meetup_router) @app.get("/") async def root(): """健康检查""" return { "status": "ok", "service": "salonapi", "routes": { "salon": "/salon/payh5, /salon/payh5/redirect, /salon/order_status, /salon/zpay_notify, /salon/xorpay_notify", "api": "/api/salon/check-user, /api/salon/ensure-user, /api/salon/complete-order, /api/salon/join, /api/salon/join/by-wechat, /api/salon/join/status, /api/salon/join/volunteers, /api/salon/xiaohongshu/profile", }, }