From 53cacfac460a7ae40ba669a498df398fb13753b0 Mon Sep 17 00:00:00 2001 From: eric Date: Fri, 13 Mar 2026 05:15:00 -0500 Subject: [PATCH] 'c1' --- .env.example | 10 +- app/__pycache__/main.cpython-312.pyc | Bin 5670 -> 8640 bytes app/main.py | 58 +- app/payment/__init__.py | 10 +- .../__pycache__/__init__.cpython-312.pyc | Bin 536 -> 603 bytes .../__pycache__/factory.cpython-312.pyc | Bin 1736 -> 2898 bytes .../__pycache__/xorpay.cpython-312.pyc | Bin 4141 -> 5731 bytes app/payment/factory.py | 28 + app/payment/xorpay.py | 26 + .../__pycache__/_digital_base.cpython-312.pyc | Bin 11825 -> 15906 bytes .../__pycache__/common.cpython-312.pyc | Bin 3842 -> 3912 bytes .../__pycache__/nomadvip.cpython-312.pyc | Bin 14976 -> 35721 bytes app/routers/_digital_base.py | 148 +++- app/routers/common.py | 6 +- app/routers/meetup.py | 31 +- app/routers/nomadvip.py | 791 ++++++++++++++++-- app/routers/wxh5.py | 28 +- .../__pycache__/payment.cpython-312.pyc | Bin 8652 -> 8793 bytes app/services/__pycache__/pb.cpython-312.pyc | Bin 1028 -> 1159 bytes app/services/payment.py | 23 +- app/services/pb.py | 5 +- run-with-logs.bat | 4 + run-with-logs.sh | 4 + run.py | 28 +- 24 files changed, 1059 insertions(+), 141 deletions(-) create mode 100644 run-with-logs.bat create mode 100644 run-with-logs.sh diff --git a/.env.example b/.env.example index 803fa98..8f5c641 100644 --- a/.env.example +++ b/.env.example @@ -17,14 +17,14 @@ XORPAY_SECRET=your_secret ZPAY_PID=2025121809351743 ZPAY_KEY=tpEi7wWIWI2kXiYVTpIG6j7it0mjVW89 -# PocketBase(check-user、ensure-user、支付回调必需) +# PocketBase(check-user、ensure-user、支付回调、nomadvip/digital/cnomadcna 共用) PB_URL=https://pocketbase.hackrobot.cn -PB_ADMIN_EMAIL=your@email.com -PB_ADMIN_PASSWORD=your_password +PB_ADMIN_EMAIL=xiaoshuang.eric@gmail.com +PB_ADMIN_PASSWORD=Xiao4669805@ -# Memos(仅 nomadvip 使用) +# Memos(nomadvip 支付成功后创建账号,与 payjsapi 同源) MEMOS_API=https://qun.hackrobot.cn/api/v1/users -MEMOS_TOKEN=your_token +MEMOS_TOKEN=eyJhbGciOiJIUzI1NiIsImtpZCI6InYxIiwidHlwIjoiSldUIn0.eyJuYW1lIjoiaGFja3JvYm90IiwiaXNzIjoibWVtb3MiLCJzdWIiOiIxIiwiYXVkIjpbInVzZXIuYWNjZXNzLXRva2VuIl0sImlhdCI6MTc1NzQwNTE0OX0.Idn7kBlxE-CoSOXwWuZ1tHGIRKHAIeDyXSafGS5OHsg # MinIO 对象存储(可选) MINIO_ENDPOINT=minioweb.hackrobot.cn diff --git a/app/__pycache__/main.cpython-312.pyc b/app/__pycache__/main.cpython-312.pyc index 296eaf938cd82243d12b440d8844e8c5a88a71b2..20add50ca69db9f2f01bd4e0d8542f1db51dc051 100755 GIT binary patch delta 4467 zcmai1du$xV8K2q5?cU4hvwgAS=Y_L_4;eclZcTzQ%JCa;iDs__R91p*=_~9Y?Bvip!5i zs?;)Vy;`nSs}(BO>reyZLZk-P*t&dnzOB_w;e1P7r#aM0t*%#4tHy*A0M%#q^9f9KFx9v%@`u7SixD@PIL?E?70NnV!aa? zj8DnHazXPOXH8}d+gI#CA!ImgQNxZ`)XOy@!eev@jo@K)oIiwyahTgn=u%h=GCYI|!G%=*Hc9^pST}$+m8knck$-&@KgZWpvAwz=D8(p{h znyqZHEmWM6lW39Soq>h&b(7oP*fv)lnkf%m+;hFWCF^Oqy--^DPIcDTcne`?;6{1H zA`kUa&I9vK*Ka^S@onLMiMQ~3V+9WbK0*aeZLn4K|1=fKU^MdW8bY>)KEQj!BB_U6 z2#t43>`e^m{bU1W+yf+xNeC*Po+n7CH;`r^YiNN@+)A-QZv~0G8vF|$-?=9yo|yC0 z&Uk7+^VF}lqKU!? zuN%bT<0*sLg&peylw;VGKWH^9MksjzKAkqk!8U>zxX`nRpt{(Tqt#a)LG%-fwM^I& z6WU8!g4v&)XNA{_Am)QFcM!-V0#g(%m3LYj8)2B1F*JtzxH0ah_#;Ogjq&f}*Kxr@ z)U2!&{kCWoaxV;oc}q&9`}*RkK8uHj1J{0GnAUoR zE*pfxbYM9Z9fDGU>7)Y&9)(Ze41}3S^e)K0bM6WE`R)rHS-Jh<{y)p@mkwN)+ix$p zE5JuZ?+w`l781QnI`h%$I`pnH%wNLva724?NYf3A*N(@EN;18Y7yr(zkvcfTVgA!w zm%(FbfaP1*c3Byq3$7|-kZCXTQ&h=NFfypBSy2U5H2ik&4jRPqWrv*ud(cNb#Jn_g z4CKD};agw6{;MzE`Oy5gbg-6UAGAMzfBYZkU;X@()1OaIZwof*!TW)j$K);hoR%xD z$5XlyO~o`q55V+AsOwl__tQ^5^|Zwgk%T1-MvZb0o|u)I=1cr%oC|{YY~Q)$L^4~~F*K%Ql09_fGvVlhyYk}BUo?NR^D6)GW7*B!v+mtlVfWWM1;EqW>$W%G(alZGQ9WkF zlUi609&T!8?n`4N6V{Ieo0@4etiyGi3ve9P_ZJ+9~gG@=6lq)AL$ z3x81VL)eu0JkMls4-|&r^uDVb; zD{olA`An|9z2GT-Gm>=&R|O39*3%DfN9Y>zZ7<`m`7uzk1GsBzm%7tyC^NYXQqG`W zcy*4;Dz~fnnz7Z{0|2oyPta^iGf-|Lkhk!Y5I~avKu188C-4}a;9f#+<4J&@vn~OW z1PvAjG=$3sWH-%TERU+WaHUMcb8urTUu z%cRoDsQOZTa8zzn<9%@>nizGp#TYl1ih=}dCX#v>TYNM%0vU=Ol^XY09`piho{rE5 z<1zU3Iv@~?3+|E}JI}p`zoI9Bj>q9zM4aeg>EO!8&gZyhh5!$=?q7(d@ONI@Kx2&kbn0eD1@Dx|)#(48( zsWQXAE7_}J$g@4D1D)N*@`1Q_)OUC=Ix?U~2jfSAU0q$lB@92;3U+5_XE5ww7G?g# zyoEu6$RC{6ntaQ(UTzx^g^z6@%esDC~(mB0-TAe;{^?_{7?pb-y z4S)58j_dx=oIA8)!ee*O*nD;WT=z3G-Oprq9mpOyn5{W9D@W$tzLQTbO32f=q?f@t zPH%VaknuG)-r*3gc^=v!o0Fvn+(%%990f9Z&AeUuokwE*TI>Y{g0dx;4?k$O6`9KO zBZ`)c#uLj&(**tpDB$77kbY%@|2@1}p^F{^0)Gd2xA*e;T%yoK7td(HDTn!pd?b<0@oLzy^(3 z*D8spVu>LY0FhphuxqS}`X#wD|DNq4CM-lzOZRaI!7B@6?@g`*3%PoPeSbkN^PP%g z8~0_JZ5{X!eu F?tjZ2*BAf* delta 1542 zcmah|TWlLe6rI^!+Z)GTzneHt9EZfFrcR(un^b9w8eRd3R6JE_g=AdM#BsW7huL)k zQA7%g!s7>|LE@u4B?Jl%5+V}H2Os@H2!0$Pl(15TP!-5$Xh4E~F|*FrZH2^0JNMkV z=gvKMcIL{1pXQc+@cX@hjmYX(<<%en*SO;h&jHxq765n`AfN&Sk)S~p+E!2nW<^cZ zB~{X0s!MmPZpb;3=Fw$Uwy{fV)4i&fVK?$)pzdl$Az&HzwwMmW zopm_SbWjkxPzZNUNhrK5)ejhIoC$z4$LaGhmKbr;S{6m@_(}R^Th|h+72}zrFYQ&L_*izrC{a&8s^b8)K;jF*TT?bN=D8vgIus z<%(ezDn(5AWwJb4wNxrsN|p-~QYDsHBbp`67R+hF--;y~CdmpU&M@hsU&}}6KmLUG zZss0kpYaS$Dv5OaYm=``Hlpc|N}pZ)_~KS(WHU3;NRNJLYz%Lt8{xCx9sfysf}U5B zW8LFPxDZbtE*M3#tmCY~q@@q**f0tu=4D08rQtcOS?;MS(F>*}PZugk!^BwM4Lv73 z(8o1-<_o04HG5en#5Cs1=5!X4elCdLtQ5&`S|DGX#>I>I3k3t`SzF{7TOFq>(U*mN zK;5xNj%49EmdBS@0U2hdndrB-t(B#X``bvYzuKu+Ws}A3x0(`3tk$q*^nvLHH|ePA!2Fsa8#Lo<(NZXPjWuF8KQr*poq|V>{BbCxQ5r z0^R8uI+SCvY})eV^Qc2 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 - logging.error(f"未处理异常: {request.url.path} - {exc}", exc_info=True) + _safe_log(logging.ERROR, "未处理异常: %s - %s", request.url.path, exc, exc_info=True) return JSONResponse(status_code=500, content={"detail": str(exc)}) -# 日志(仅控制台,不写文件,避免 git 本地/线上不一致) +logging.raiseExceptions = False logging.basicConfig( level=logging.INFO, - format="%(asctime)s - %(message)s", - handlers=[logging.StreamHandler()], + 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 any( + segment in path for segment in ("/nomadvip", "/digital", "/cnomadcna", "/nomadlms") + ) + # 启动时校验 BASE_URL(线上部署必须为公网可访问地址,否则 ZPAY 无法回调) def _check_base_url(): url = (BASE_URL or "").strip().lower() @@ -93,6 +130,17 @@ async def health(check_zpay: str = ""): 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"[payjsapi] >>> {request.method} {path}\n") + resp = await call_next(request) + if _should_trace_request(path): + _safe_stderr_write(f"[payjsapi] <<< {request.method} {path} {resp.status_code}\n") + return resp + # 挂载路由 app.include_router(nomadvip_router) app.include_router(digital_router) @@ -110,7 +158,7 @@ async def root(): "status": "ok", "service": "payjsapi", "routes": { - "nomadvip": "/nomadvip/payh5, /nomadvip/payh5/redirect, /nomadvip/zpay_order_status, /nomadvip/zpay_notify, /nomadvip/xorpay_notify", + "nomadvip": "/nomadvip/payh5, /nomadvip/payh5/redirect, /nomadvip/check_vip, /nomadvip/check_vip_by_email, /nomadvip/order_status, /nomadvip/zpay_order_status, /nomadvip/zpay_notify, /nomadvip/xorpay_notify", "digital": "/digital/payh5, /digital/payh5/redirect, /digital/zpay_order_status, /digital/zpay_notify, /digital/xorpay_notify", "cnomadcna": "/cnomadcna/payh5, ...", "nomadlms": "/nomadlms/payh5, ...", diff --git a/app/payment/__init__.py b/app/payment/__init__.py index 54ab8fa..6b6ab1e 100644 --- a/app/payment/__init__.py +++ b/app/payment/__init__.py @@ -5,7 +5,13 @@ from .base import PaymentProvider from .xorpay import XorPayProvider from .zpay import ZPayProvider -from .factory import get_payment_provider, get_payment_provider_by_name, reset_provider +from .factory import ( + get_payment_provider, + get_payment_provider_by_name, + reset_provider, + resolve_provider, + resolve_channel, +) __all__ = [ "PaymentProvider", @@ -14,4 +20,6 @@ __all__ = [ "get_payment_provider", "get_payment_provider_by_name", "reset_provider", + "resolve_provider", + "resolve_channel", ] diff --git a/app/payment/__pycache__/__init__.cpython-312.pyc b/app/payment/__pycache__/__init__.cpython-312.pyc index 79309f6c6d423fc1b551b106f34756c016187227..751e5ff57a298f7934cba94a89dbd7cac3946b97 100644 GIT binary patch delta 164 zcmbQia+`(kG%qg~0}y1N-<+w*G?7n&F<_#)y-+T96n8F96b~aqIztNEBE~4*i3JUe ztP?-FsNNDNN-fUMDNBtnD9SI(Oi3-e#Sarn&PdG5OU=>bm~74{$6Le+G`I+4yq_lD x^l=1Bhyy~An{3nlbflLy+{Zs2>_{yDZc;! delta 131 zcmcc3GJ}QhG%qg~0}upAZphrmIFV0+(PN^zJr^TGIztNEBE~50i4_gZ%(p})3o?pK z=4aGX)?_PU2C6S&0THYqf(=Blg9r{F;it(nxtGyp@@+;BE+HVF5r~VGChIau2;FB0 TzsL~&iG`h;sgb=%04MWfVoYak1Cfzk z1PL^WFd<4D@*_}Mfr{j$swxp`(QBksD#iz_RSr?(-Oy9q(o;{JS-ix_p`W!gZ@zhc z-`n|SpH*(uw?D6}lmUt8?>`03Iste^7j}@f72Chfv`?II0{VkqScN=Lx-01Jf4*)+QtN(v(k#Sv5u#|okMs(RzV02wp=baitjiG% z;a=ZGG!XW4wiPl{OrLEB1Hc^+V}==xWdLOQ$ol2MggO7zypi5YuNmK6-CBHPK1`ao9v6N|nJe>uelzWjKTfeIq52vt1qL z)HU<|s`>Nu*5agbcgf;hwi#w>&B#m_GV{j7_pj&16}!PoA(JvDCSS!9W0sN*R2Dap zSHRU|(Ah!1F7DW|vU*jJItkiNESAdYf)>VsL0#w%4Izx1$zN3w@1ruUbZDbm9JyFJ zM_y59BR)+eDSAqZf*S~o>(v-(q5de^y?eNWgcB2mX+Ib z@`s!9p{#r;Cm%_4cF{Bl!Pva*ts_gSpTOJi}$ zIhAuCTX!7G%iaySAr)sgoh?~sOU~Jv_$)8&Ngd5fjb*eKuJ$D{=jvGJJMag@j$RQ6 zqhK%T)4%}g_rHJXtxtm|ygOioQQ61|0!!V${ggDqxVLJg=L##UR`VCkTYdCqyvbbO zAsOih=G<6e<%T((EUewrg`r{6NW7P*s0{8TlSV7<9G&jAPp;$!e?<1WDT#ha3GmWY zV|iA&o}M1Hel{zN9HwV$@hnkm+v>x=j*o*O80kkRG4)KVXg|yb#m{|z*KMQQc)UqCBNr*F=m28fwv(bGEK@t* Sy2LLWC;|eX)c!*!tNwqxGiCJv delta 93 zcmca4c7j*^G%qg~0}z;rZ^+!p%E0g##DM`8DC2X>MDbISx9^V>qfm{iHX_6-e797xQFeWTQInt=KTKu`~N!s zd;kCc>8*-qJM0f_HY=d(*bfWQGxjOF4|Yt|ZVy1j4Vc0vBp`9Xe9;oNK)`_`0GTEL zGAovOp7{U}mZoB#yBi?@(?9`Xh(MS_FwCP8BEu7@JVihzMd%TbeL@IJ$YK1PvpixN z3*Gs2OLWV07bsRmD56o3>ow=59JN*C9*$`Yt!b;IrvP~-Oko=;+$yD~+IxgB^3r1+ z|5I%VNK^As8>f*bhl(hlk4la7ij@K?-D+z>{s}Sc8gm57zT<)rI>}D{D)cPe=bbR` z2G{XQ9MXE~LpR8(@Chs;ABthFna;n9Z z6aqYUGQNaCEd`Z*=ZcC7oKLAPcMT3Hc&xEYjrPVa`On7j>B!h=93PG%1vhL}mNMTK z3Lk^4z3`g*x_`#MY_Fc=R~?04T>k9xwWjMWGcD=fjK3j6X2p)Yx2Y}O!JvNOrFa~| zI&#QdUEIJV#SB8wC;^Yr!oLcbhGE@zZwmXjB+x?o9ip^H8;*1#op?@VU&dP)SE5D&2r;{ z1OXY9$~}BiNOCv%&B+=UlVZO_?n%W01WA$nD3!>gP~{+qf^m~(BfUbohfe1_mkYKT zwBJE}{4k$+TF1>vGcpZ>q?yD8FS#X2B8}mJBqsP$mQ9yjSR^J*NSYNoK^4#nazO#8 z2G}=lK~~MSDM`NeLwU2bzs~^##w}MYof~unj(}1A92kKCx#LnwH*D`wXlev64LSfK-?YAS1p6wsYG5>=J1o;coU6iiHMF!E;M)kHj|Dwy?jF0T&CsS*V`#JQCl z<$}Q!8|AJzrlY%Y`lZVMOtdlWZmpxZoiwdkWGS@jEuT(KCDU!UP4~=?y+d0zMXp(vYaYwBS(kr$ z|I~hR@R6%7ePObF)#IaghHu7~uA6Jk6a_P$ohzQ^WlwX)6I$^cSoR#qcn(b-e(Eb* z+Sa_}Yt9x|WJ@crpPf0I{wP!0_^9;cZS;NLoxZyxndT$(X|XZu^{-2!+r9=woBgjB zGH@O&`V&AYlr5>e*>+o;KfdhR^NYOa?=_wZU#VsE9lvR zA>*q=iX?5TvcNxBbpvklldV35-={ogt*cuSIG#|jHip?h2CiekS^%>##{vU3nm9y3 z7cRu(1GtqvA7OBe3HfMD!|Z0jUIy$^Fe-;xF%4i7p^np8o&Zk-@47%D_5p{56Wd|B za}6*$*GYc1XZAe@P;{@G3dGP{(;A@UuFD|z&|=fl>CVN_{n!g8&K<(y)2{u19kY$x raTIg-6j^lCTQ5)vyE)VV`N!cadIlW7gUT1CE(k+cyIulDjR^k&aG&4U delta 781 zcmZvaOK;Oa5XX0H$IjD9YnMJ~p)V2~5l|6`P^4bkQc4jOH{?qcx|=pqX`;0oDW?L; z0dZ=z2rhgAP*3?7fDpIJmmUyz5aj>|#7sb?2W-vHvorshvDf?Q!v47ZHj_y*ytb$I z>raz=_7L3ME955(9@{h@TD}ELV@r&i9mXwTz0pa_m>=J3J##f+>?IS-2hMzrgRk>p z<-*F2&FRj*(s@c4JdTz_OQ5CE`UHBz*oF5Wdk%U@VTe>sqf9Sro{ZG`x!W=QG#@w_ z<)+Bg41TI(`1Zd{@jvATA4E|W3yhk^htSWpZEvJo)-D0qYD3=zr~5V15Arbv+y!WiL%3YSrCZKJxiB{nO~ zYP%A)n?la0$;3RI?$#0|4NB^HD({~~rDsL@x(H>8nv4@B2$O^K^8<)RXV)o?rT zK;A&Pr~RF$#_U`C=-7%u& diff --git a/app/payment/factory.py b/app/payment/factory.py index 06fd5b4..40ca263 100644 --- a/app/payment/factory.py +++ b/app/payment/factory.py @@ -49,3 +49,31 @@ def reset_provider(): """重置 Provider(用于测试或切换渠道)""" global _provider_instance _provider_instance = None + + +def resolve_provider( + device: str | None = None, + req_provider: str | None = None, + user_agent: str | None = None, +) -> PaymentProvider: + """ + 根据设备解析支付渠道: + - 微信内(device=wechat 或 UA 含 MicroMessenger) → xorpay + - PC/手机浏览器 → zpay + 显式传入的 provider 优先。 + """ + req = (req_provider or "").strip().lower() + if req in ("xorpay", "zpay"): + return get_payment_provider_by_name(req) + dev = (device or "pc").lower() + if dev == "wechat": + return get_payment_provider_by_name("xorpay") + ua = (user_agent or "").lower() + if "micromessenger" in ua: + return get_payment_provider_by_name("xorpay") + return get_payment_provider_by_name("zpay") + + +def resolve_channel(item_channel: str | None) -> str: + """支付方式固定为微信支付,支付宝暂不提供""" + return "wxpay" diff --git a/app/payment/xorpay.py b/app/payment/xorpay.py index 5414b5c..99d2941 100644 --- a/app/payment/xorpay.py +++ b/app/payment/xorpay.py @@ -5,6 +5,8 @@ XorPay 支付渠道实现 import hashlib from typing import Any, Dict, Optional +import requests + from .base import PaymentProvider @@ -24,6 +26,10 @@ class XorPayProvider(PaymentProvider): raw = name + pay_type + price + order_id + notify_url + self.secret return hashlib.md5(raw.encode("utf-8")).hexdigest().lower() + def _query2_sign(self, order_id: str) -> str: + raw = order_id + self.secret + return hashlib.md5(raw.encode("utf-8")).hexdigest().lower() + def create_order( self, *, @@ -80,3 +86,23 @@ class XorPayProvider(PaymentProvider): def success_response(self) -> str: return "ok" + + def query_order_status(self, order_id: str, timeout: int = 10) -> Dict[str, Any]: + url = f"https://xorpay.com/api/query2/{self.aid}" + params = { + "order_id": order_id, + "sign": self._query2_sign(order_id), + } + try: + response = requests.get(url, params=params, timeout=timeout) + response.raise_for_status() + data = response.json() or {} + status = str(data.get("status", "")).strip().lower() + return { + "paid": status in ("payed", "success"), + "status": status, + "pay_price": data.get("pay_price") or data.get("price") or "", + "raw": data, + } + except Exception as error: + return {"paid": False, "error": str(error)} diff --git a/app/routers/__pycache__/_digital_base.cpython-312.pyc b/app/routers/__pycache__/_digital_base.cpython-312.pyc index 3cacc7b495839d7ba68d0567809fd2f69a3c623d..5ab043ff705d878f69c97b958e471bd96aaf8b63 100644 GIT binary patch literal 15906 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 Nv3LJ%wvgrQ{{h0^?g;<@ delta 5839 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 diff --git a/app/routers/__pycache__/common.cpython-312.pyc b/app/routers/__pycache__/common.cpython-312.pyc index 97523289445236070cfc856aac179a5c8946f8db..f6ebe17b2ca2cfed62e43a4f13cb0c17c32ed85e 100644 GIT binary patch delta 819 zcmYjPO>7cD6rR~%7T7`?ZEG4R2(&_NBq-G)X=7V4ZP9}^UN=})gu-q*yTqiS2{tjt zi$9qdJ(;FA(}NgpJ=eycOAj2(9z1DcqPKcbPtGimGRb`Jz5U+2-`(tP~!17CP=&mmf{&3muxKC_{7ErI|*g{l^u2URE8a#5BA|P zE6~vxEVf-xPYf80a#`GCx!kx^=+bjtB5-Zi009iMt6;V;?`t1|mi?&96Y{ z!_Ngj6EW~xVcF}n#Ijx}6t#lI6}7uMX2dW~imyU(lEg!KDJ zcXYjsW~qIN+H6rXEzT@fRMd`x_uRo16ee6$K(g&(^P8DB!}~h6F{XY~RRvKQ zxzQ~ZKdE~-{EI_q8DaDSIT+&HNzB76H;zSE;iAI<#%0p+60my~ z_hxeW6u+GkslA=$^V9G;_#S6rK^YmoM7w-Id+hZj%^>3e)D=yd_sw_8BrbHnD2o_I z!>d?>r{QzUSr%i-Pt=-OvuvH1cBAS{CBsB!p&$eHAza~sWmU;l=Fc#nr*pf4Q>vN7 zF9LtUiQ7quuob$zZB*OtvGhNgm>nN|J5epPv;Xa}2y4 Xu*Lk7=WCYHsG65a0k$HW0*m|ykW#0R diff --git a/app/routers/__pycache__/nomadvip.cpython-312.pyc b/app/routers/__pycache__/nomadvip.cpython-312.pyc index b6fbc5fca36d33b6f327262ba136d4d7d3166082..d5bd854d2b7fa437f2950325c094a9e728ba92ad 100644 GIT binary patch literal 35721 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 PShW=7-`3zK+kgKLTyf66 literal 14976 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 None: + try: + logging.log(level, message, *args, **kwargs) + except Exception: + pass + + def create_digital_router(prefix: str, project_name: str, site_id: str | None = None) -> APIRouter: """ 创建 digital/cnomadcna/nomadlms 的支付路由 @@ -31,12 +43,29 @@ def create_digital_router(prefix: str, project_name: str, site_id: str | None = return f"{base}{path}" @router.post("/payh5") - async def payh5(item: dict): - """创建 H5 支付订单""" - print(f"{project_name}/payh5 item:", item) - total_fee_yuan = f"{item['total_fee'] / 100:.2f}" + async def payh5(item: dict, request: Request): + """创建 H5 支付订单。默认微信支付,PC/手机用 zpay,微信内用 xorpay,支付宝暂不提供""" + req_provider = (item.get("provider") or "").strip().lower() or None + device = (item.get("device") or "pc").lower() + ua = (request.headers.get("user-agent") or "").strip() + provider = resolve_provider(device, req_provider, ua) + channel = resolve_channel(item.get("channel")) user_id = item.get("user_id", "unknown") - pay_type = item.get("type", "unknown").lower() + pay_type = (item.get("type") or "unknown").lower() + _safe_log( + logging.INFO, + "[payjsapi] %s/payh5 user_id=%s type=%s provider=%s channel=%s", + project_name, + user_id, + pay_type, + provider.name, + channel, + ) + try: + total_fee = int(item.get("total_fee", 80)) + except (TypeError, ValueError): + total_fee = 80 + total_fee_yuan = f"{total_fee / 100:.2f}" random_suffix = "".join(random.choices(string.hexdigits.lower(), k=8)) timestamp = int(time.time()) order_id = f"{user_id}_{pay_type}_order_{timestamp}_{random_suffix}" @@ -47,10 +76,8 @@ def create_digital_router(prefix: str, project_name: str, site_id: str | None = "vip": "VIP会员充值", } name = type_map.get(pay_type, "商品支付") - provider = get_payment_provider() notify_path = "/xorpay_notify" if provider.name == "xorpay" else "/zpay_notify" notify_url = _notify_path(notify_path) - channel = item.get("channel", "alipay") result = provider.create_order( order_id=order_id, name=name, @@ -60,9 +87,19 @@ def create_digital_router(prefix: str, project_name: str, site_id: str | None = return_url=item.get("return_url"), client_ip=item.get("client_ip"), ) + _safe_log( + logging.INFO, + "[payjsapi] %s/payh5 created order_id=%s provider=%s notify_url=%s return_url=%s", + project_name, + order_id, + provider.name, + notify_url, + item.get("return_url") or "", + ) if provider.name == "xorpay": return { "status": "ok", + "order_id": order_id, "xorpay_params": result.get("params", {}), "xorpay_url": result.get("pay_url", ""), "provider": provider.name, @@ -79,17 +116,27 @@ def create_digital_router(prefix: str, project_name: str, site_id: str | None = @router.post("/payh5/redirect") async def payh5_redirect(item: dict, request: Request): - """ZPAY mapi 接口,返回 302 或二维码""" + """ZPAY mapi 接口,返回 302 或二维码。PC/手机用 zpay,默认微信支付,支付宝暂不提供""" from fastapi.responses import RedirectResponse, JSONResponse - provider = get_payment_provider() - if provider.name != "zpay": - raise HTTPException(status_code=400, detail="payh5/redirect 仅支持 zpay 渠道") - total_fee = float(item.get("total_fee", 80)) + provider = get_payment_provider_by_name("zpay") + channel = resolve_channel(item.get("channel")) + _safe_log( + logging.INFO, + "[payjsapi] %s/payh5/redirect user_id=%s type=%s device=%s channel=%s", + project_name, + item.get("user_id", ""), + item.get("type", ""), + item.get("device", "pc"), + channel, + ) + try: + total_fee = float(item.get("total_fee", 80)) + except (TypeError, ValueError): + total_fee = 80.0 total_fee_yuan = f"{total_fee / 100:.2f}" user_id = item.get("user_id", "unknown") pay_type = item.get("type", "unknown").lower() - channel = item.get("channel", "alipay") return_url = item.get("return_url") device = item.get("device", "pc") client_ip = item.get("client_ip") or (request.client.host if request.client else "127.0.0.1") @@ -107,8 +154,6 @@ def create_digital_router(prefix: str, project_name: str, site_id: str | None = } name = type_map.get(pay_type, "商品支付") notify_url = _notify_path("/zpay_notify") - if not isinstance(provider, ZPayProvider): - raise HTTPException(status_code=400, detail="payh5/redirect 仅支持 zpay") result = provider.create_order_api( order_id=order_id, name=name, @@ -121,6 +166,14 @@ def create_digital_router(prefix: str, project_name: str, site_id: str | None = ) if result.get("status") != "ok": raise HTTPException(status_code=500, detail=result.get("msg", "ZPAY mapi 创建订单失败")) + _safe_log( + logging.INFO, + "[payjsapi] %s/payh5/redirect created order_id=%s device=%s channel=%s", + project_name, + order_id, + device, + channel, + ) payurl = result.get("payurl") payurl2 = result.get("payurl2") img = result.get("img") @@ -146,23 +199,66 @@ def create_digital_router(prefix: str, project_name: str, site_id: str | None = raise HTTPException(status_code=400, detail="仅 zpay 渠道支持") result = provider.query_order_status(out_trade_no, timeout=15) if result.get("error"): - logging.warning(f"{project_name} ZPAY 订单查询失败: {result.get('error')}") + _safe_log(logging.WARNING, "%s ZPAY 订单查询失败: %s", project_name, result.get("error")) return result + @router.get("/order_status") + async def order_status(order_id: str = "", out_trade_no: str = ""): + order_id = (order_id or out_trade_no or "").strip() + if not order_id: + return {"paid": False, "error": "missing order_id"} + + try: + from ..services.pb import get_pb_client + + pb = get_pb_client() + records = pb.collection("payments").get_list( + 1, 1, {"filter": f'order_id = "{order_id}"'} + ) + if records.items: + return {"paid": True, "source": "pocketbase"} + except Exception as error: + _safe_log(logging.WARNING, "%s order_status PocketBase failed: %s", project_name, error) + + try: + xorpay = get_payment_provider_by_name("xorpay") + xorpay_result = xorpay.query_order_status(order_id, timeout=8) + if xorpay_result.get("paid"): + return { + "paid": True, + "source": "xorpay", + "status": xorpay_result.get("status", ""), + } + except Exception as error: + _safe_log(logging.WARNING, "%s order_status XorPay failed: %s", project_name, error) + + try: + zpay = get_payment_provider_by_name("zpay") + if isinstance(zpay, ZPayProvider): + zpay_result = zpay.query_order_status(order_id, timeout=8) + if zpay_result.get("paid"): + return { + "paid": True, + "source": "zpay", + "status": zpay_result.get("status", ""), + } + except Exception as error: + _safe_log(logging.WARNING, "%s order_status ZPAY failed: %s", project_name, error) + + return {"paid": False} + @router.post("/xorpay_notify") async def xorpay_notify(request: Request): """XorPay 异步通知""" form_data = await request.form() data = {k: v for k, v in form_data.items()} - logging.info(f"{project_name} XorPay 异步通知: {data}") + _safe_log(logging.INFO, "%s XorPay 异步通知: %s", project_name, data) order_id = data.get("order_id", "") if order_id in processed_orders: return "ok" - provider = get_payment_provider() - if provider.name != "xorpay": - raise HTTPException(status_code=400, detail="xorpay_notify only for xorpay") + provider = get_payment_provider_by_name("xorpay") if not provider.verify_notify(data): - logging.error(f"{project_name} XorPay 验签失败: {data}") + _safe_log(logging.ERROR, "%s XorPay 验签失败: %s", project_name, data) raise HTTPException(status_code=400, detail="sign error") parsed = provider.parse_notify(data) if parsed.get("trade_status") != "success": @@ -185,15 +281,13 @@ def create_digital_router(prefix: str, project_name: str, site_id: str | None = else: form_data = await request.form() data = {k: v for k, v in form_data.items()} - logging.info(f"{project_name} ZPAY 异步通知: {data}") + _safe_log(logging.INFO, "%s ZPAY 异步通知: %s", project_name, data) order_id = data.get("out_trade_no", "") if order_id in processed_orders: return "success" - provider = get_payment_provider() - if provider.name != "zpay": - raise HTTPException(status_code=400, detail="zpay_notify only for zpay") + provider = get_payment_provider_by_name("zpay") if not provider.verify_notify(data): - logging.error(f"{project_name} ZPAY 验签失败: {data}") + _safe_log(logging.ERROR, "%s ZPAY 验签失败: %s", project_name, data) raise HTTPException(status_code=400, detail="sign error") parsed = provider.parse_notify(data) if parsed.get("trade_status") != "success": diff --git a/app/routers/common.py b/app/routers/common.py index a648708..40391bd 100644 --- a/app/routers/common.py +++ b/app/routers/common.py @@ -15,7 +15,7 @@ router = APIRouter(tags=["common"]) @router.post("/submit_meetup_application") async def submit_meetup_application(item: dict): """数字游民社区报名表单""" - print("submit_meetup_application item:", item) + logging.info("submit_meetup_application item: %s", item) user_id = item.get("user_id") if not user_id: raise HTTPException(status_code=400, detail="missing user_id") @@ -41,7 +41,7 @@ async def submit_meetup_application(item: dict): logging.info(f"meetup 申请表单提交成功(待支付) - user_id: {user_id}, record_id: {record.id}") return {"status": "ok", "record_id": record.id} except Exception as e: - print(f"meetup 申请提交失败: {e}") + logging.error("meetup 申请提交失败: %s", e) logging.error(f"meetup 申请提交失败 - user_id: {user_id}, error: {e}") raise HTTPException(status_code=500, detail="submit failed") @@ -61,5 +61,5 @@ async def create_user(item: CreateUserRequest): username = item.username or f"user{timenew}" password = item.password or "12345678" result = sdk.create_user_by_username(username=username, password=password) - print("memos_result-", result) + logging.info("memos_result- %s", result) return result diff --git a/app/routers/meetup.py b/app/routers/meetup.py index dd74be2..6bd2aba 100644 --- a/app/routers/meetup.py +++ b/app/routers/meetup.py @@ -14,9 +14,18 @@ from ..config import PB_URL, PB_ADMIN_EMAIL, PB_ADMIN_PASSWORD router = APIRouter(prefix="/api/meetup", tags=["meetup"]) DEFAULT_PASSWORD = "12345678" # PocketBase 默认要求至少 8 位 +# Admin token 缓存,避免每次请求都向 PocketBase 认证(PB 远程时易超时) +_ADMIN_TOKEN_CACHE: tuple[str, float] | None = None +_TOKEN_CACHE_TTL = 300 # 5 分钟 + def _get_admin_token() -> tuple[str | None, str | None]: - """返回 (token, error_detail)。token 为 None 时 error_detail 为失败原因""" + """返回 (token, error_detail)。token 为 None 时 error_detail 为失败原因。带 5 分钟缓存。""" + global _ADMIN_TOKEN_CACHE + now = time.time() + if _ADMIN_TOKEN_CACHE and _ADMIN_TOKEN_CACHE[1] > now: + return _ADMIN_TOKEN_CACHE[0], None + if not PB_ADMIN_EMAIL or not PB_ADMIN_PASSWORD: return None, "PB_ADMIN_EMAIL 或 PB_ADMIN_PASSWORD 未配置" base = (PB_URL or "").rstrip("/") @@ -26,11 +35,14 @@ def _get_admin_token() -> tuple[str | None, str | None]: r = requests.post( f"{base}{path}", json={"identity": PB_ADMIN_EMAIL, "password": PB_ADMIN_PASSWORD}, - timeout=10, + timeout=4, ) if r.status_code == 200: data = r.json() - return data.get("token"), None + token = data.get("token") + if token: + _ADMIN_TOKEN_CACHE = (token, now + _TOKEN_CACHE_TTL) + return token, None if r.status_code == 404: continue # 尝试下一个路径 try: @@ -56,9 +68,10 @@ class EnsureUserRequest(BaseModel): password: str | None = None -def _check_site_vip(user_id: str, site_id: str = "meetup") -> bool: - """检查用户是否有有效 site_vip(未过期)""" - token, _ = _get_admin_token() +def _check_site_vip(user_id: str, site_id: str = "meetup", token: str | None = None) -> bool: + """检查用户是否有有效 site_vip(未过期)。可传入 token 避免重复认证。""" + if not token: + token, _ = _get_admin_token() if not token: return False try: @@ -72,7 +85,7 @@ def _check_site_vip(user_id: str, site_id: str = "meetup") -> bool: "sort": "-expires_at", }, headers={"Authorization": f"Bearer {token}"}, - timeout=10, + timeout=4, ) if r.status_code != 200: return False @@ -115,7 +128,7 @@ async def check_user(item: CheckUserRequest): f"{PB_URL}/api/collections/users/records", params={"filter": filter_val, "perPage": 1}, headers={"Authorization": f"Bearer {token}"}, - timeout=10, + timeout=4, ) if r.status_code != 200: raise HTTPException(status_code=500, detail="查询失败") @@ -123,7 +136,7 @@ async def check_user(item: CheckUserRequest): items = data.get("items") or [] exists = len(items) > 0 user_id = items[0].get("id") if items else None - vip = _check_site_vip(user_id, "meetup") if user_id else False + vip = _check_site_vip(user_id, "meetup", token) if user_id else False return {"ok": True, "exists": exists, "vip": vip, "user_id": user_id} except HTTPException: raise diff --git a/app/routers/nomadvip.py b/app/routers/nomadvip.py index 9b63300..406b3d8 100644 --- a/app/routers/nomadvip.py +++ b/app/routers/nomadvip.py @@ -1,14 +1,21 @@ -"""nomadvip 项目支付接口:/nomadvip/*,支持 xorpay/zpay(PAYMENT_PROVIDER)""" +"""nomadvip 项目支付接口:/nomadvip/*,支持 xorpay / zpay。""" + import logging import random import string import time -from fastapi import APIRouter, Request, HTTPException -from fastapi.responses import RedirectResponse, JSONResponse +import requests +from fastapi import APIRouter, HTTPException, Request +from fastapi.responses import JSONResponse, RedirectResponse -from ..config import BASE_URL -from ..payment import get_payment_provider, get_payment_provider_by_name +from ..config import BASE_URL, PB_ADMIN_EMAIL, PB_ADMIN_PASSWORD, PB_URL +from ..payment import ( + get_payment_provider, + get_payment_provider_by_name, + resolve_provider, + resolve_channel, +) from ..payment.zpay import ZPayProvider from ..services import handle_payment_success, processed_orders @@ -16,15 +23,27 @@ router = APIRouter(prefix="/nomadvip", tags=["nomadvip"]) def _get_notify_path(provider_name: str) -> str: - return f"{BASE_URL.rstrip('/')}/nomadvip/xorpay_notify" if provider_name == "xorpay" else f"{BASE_URL.rstrip('/')}/nomadvip/zpay_notify" + if provider_name == "xorpay": + return f"{BASE_URL.rstrip('/')}/nomadvip/xorpay_notify" + return f"{BASE_URL.rstrip('/')}/nomadvip/zpay_notify" @router.post("/payh5") -async def nomadvip_payh5(item: dict): - """nomadvip 专用:创建支付订单,支持 per-request provider 覆盖(微信用 xorpay,PC/H5 用 zpay)""" - print("nomadvip/payh5 item:", item) - req_provider = (item.get("provider") or "").strip().lower() - provider = get_payment_provider_by_name(req_provider) if req_provider in ("xorpay", "zpay") else get_payment_provider() +async def nomadvip_payh5(item: dict, request: Request): + """创建支付订单。微信内用 xorpay,PC/手机用 zpay,默认微信支付,支付宝暂不提供""" + req_provider = (item.get("provider") or "").strip().lower() or None + device = (item.get("device") or "pc").lower() + ua = (request.headers.get("user-agent") or "").strip() + provider = resolve_provider(device, req_provider, ua) + channel = resolve_channel(item.get("channel")) + logging.info( + "[payjsapi] payh5 user_id=%s type=%s provider=%s channel=%s", + item.get("user_id", ""), + item.get("type", ""), + provider.name, + channel, + ) + total_fee = int(item.get("total_fee", 880)) total_fee_yuan = f"{total_fee / 100:.2f}" user_id = item.get("user_id", "unknown") @@ -32,6 +51,7 @@ async def nomadvip_payh5(item: dict): random_suffix = "".join(random.choices(string.hexdigits.lower(), k=8)) timestamp = int(time.time()) order_id = f"{user_id}_{pay_type}_order_{timestamp}_{random_suffix}" + type_map = { "book": "购买书籍", "meetup": "数字游民社区报名", @@ -40,7 +60,6 @@ async def nomadvip_payh5(item: dict): } name = type_map.get(pay_type, "VIP会员充值") notify_url = _get_notify_path(provider.name) - channel = item.get("channel", "alipay") result = provider.create_order( order_id=order_id, name=name, @@ -50,15 +69,27 @@ async def nomadvip_payh5(item: dict): return_url=item.get("return_url"), client_ip=item.get("client_ip"), ) + logging.info( + "[payjsapi] payh5 created order_id=%s provider=%s notify_url=%s return_url=%s", + order_id, + provider.name, + notify_url, + item.get("return_url") or "", + ) + if provider.name == "xorpay": return { "status": "ok", + "order_id": order_id, "xorpay_params": result.get("params", {}), "xorpay_url": result.get("pay_url", ""), "provider": provider.name, } + params = result.get("params", {}) - params_str = {k: str(v) for k, v in params.items() if v is not None and str(v).strip() != ""} + params_str = { + k: str(v) for k, v in params.items() if v is not None and str(v).strip() != "" + } return { "status": "ok", "params": params_str, @@ -70,35 +101,49 @@ async def nomadvip_payh5(item: dict): @router.post("/payh5/redirect") async def nomadvip_payh5_redirect(item: dict, request: Request): - """nomadvip 专用:ZPAY mapi 接口(xorpay 无需 redirect,直接 payh5 即可)""" - req_provider = (item.get("provider") or "").strip().lower() - provider = get_payment_provider_by_name("zpay") if req_provider == "zpay" else get_payment_provider() - if not isinstance(provider, ZPayProvider): - raise HTTPException(status_code=400, detail="payh5/redirect 仅支持 zpay,xorpay 请直接使用 payh5") - logging.info(f"nomadvip payh5/redirect 请求: item={item}") + """ZPAY mapi 接口,PC/手机用 zpay,默认微信支付,支付宝暂不提供""" + provider = get_payment_provider_by_name("zpay") + channel = resolve_channel(item.get("channel")) + + logging.info("nomadvip payh5/redirect request: item=%s", item) try: total_fee = float(item.get("total_fee", 880)) except (TypeError, ValueError): - logging.warning(f"nomadvip payh5/redirect 无效 total_fee: {item.get('total_fee')}") + logging.warning( + "nomadvip payh5/redirect invalid total_fee=%s", item.get("total_fee") + ) total_fee = 880.0 + total_fee_yuan = f"{total_fee / 100:.2f}" user_id = item.get("user_id", "unknown") pay_type = (item.get("type") or "vip").lower() - channel = item.get("channel", "alipay") return_url = item.get("return_url") device = item.get("device", "pc") - client_ip = item.get("client_ip") or (request.client.host if request.client else "127.0.0.1") + client_ip = item.get("client_ip") or ( + request.client.host if request.client else "127.0.0.1" + ) forwarded = request.headers.get("x-forwarded-for") if forwarded: client_ip = forwarded.split(",")[0].strip() + random_suffix = "".join(random.choices(string.hexdigits.lower(), k=8)) timestamp = int(time.time()) order_id = f"{user_id}_{pay_type}_order_{timestamp}_{random_suffix}" - type_map = {"book": "购买书籍", "meetup": "数字游民社区报名", "video": "视频解锁", "vip": "VIP会员充值"} + + # 追加 order_id,供 paid 页在 cookie 丢失时恢复 user_id。 + if return_url and "order_id=" not in return_url and "out_trade_no=" not in return_url: + sep = "&" if "?" in return_url else "?" + return_url = f"{return_url}{sep}order_id={order_id}" + + type_map = { + "book": "购买书籍", + "meetup": "数字游民社区报名", + "video": "视频解锁", + "vip": "VIP会员充值", + } name = type_map.get(pay_type, "VIP会员充值") notify_url = f"{BASE_URL.rstrip('/')}/nomadvip/zpay_notify" - if not isinstance(provider, ZPayProvider): - raise HTTPException(status_code=400, detail="payh5/redirect 仅支持 zpay") + result = provider.create_order_api( order_id=order_id, name=name, @@ -111,82 +156,684 @@ async def nomadvip_payh5_redirect(item: dict, request: Request): ) if result.get("status") != "ok": err_msg = result.get("msg", "ZPAY mapi 创建订单失败") - logging.error(f"nomadvip payh5/redirect ZPAY 创建订单失败: {err_msg}, result={result}") + logging.error( + "nomadvip payh5/redirect ZPAY create failed: %s result=%s", + err_msg, + result, + ) raise HTTPException(status_code=500, detail=err_msg) + payurl = result.get("payurl") payurl2 = result.get("payurl2") img = result.get("img") if device == "pc" and img: - return JSONResponse(status_code=200, content={ - "status": "ok", "qrcode_mode": True, "order_id": order_id, - "img": img, "payurl": payurl, "return_url": return_url, "channel": channel, - }) + return JSONResponse( + status_code=200, + content={ + "status": "ok", + "qrcode_mode": True, + "order_id": order_id, + "img": img, + "payurl": payurl, + "return_url": return_url, + "channel": channel, + }, + ) if device == "wechat" and channel.lower() in ("wxpay", "wx", "wechat"): - return JSONResponse(status_code=200, content={"use_form": True, "order_id": order_id}) + return JSONResponse( + status_code=200, content={"use_form": True, "order_id": order_id} + ) + headers = {"X-Pay-Order-Id": order_id} if device == "h5" and channel.lower() in ("wxpay", "wx", "wechat") and payurl2: return RedirectResponse(url=payurl2, status_code=302, headers=headers) if payurl: return RedirectResponse(url=payurl, status_code=302, headers=headers) - logging.error(f"nomadvip payh5/redirect ZPAY 未返回支付链接: device={device}, channel={channel}, result keys={list(result.keys())}") + + logging.error( + "nomadvip payh5/redirect ZPAY missing pay url device=%s channel=%s keys=%s", + device, + channel, + list(result.keys()), + ) raise HTTPException(status_code=500, detail="ZPAY 未返回支付链接") @router.get("/zpay_order_status") async def nomadvip_zpay_order_status(out_trade_no: str): - """nomadvip 专用:查询 ZPAY 订单支付状态(xorpay 无此接口)""" + """查询 ZPAY 订单支付状态。""" provider = get_payment_provider() if not isinstance(provider, ZPayProvider): raise HTTPException(status_code=400, detail="zpay_order_status 仅支持 zpay") result = provider.query_order_status(out_trade_no, timeout=15) if result.get("error"): - logging.warning(f"nomadvip ZPAY 订单查询失败: {result.get('error')}") + logging.warning( + "nomadvip ZPAY order status failed: %s", result.get("error") + ) return result -@router.get("/order_status") -async def nomadvip_order_status(order_id: str): - """nomadvip 通用:查询订单支付状态。zpay 先查 ZPAY 实时,xorpay 或失败时查 PocketBase""" - zpay = get_payment_provider_by_name("zpay") - if isinstance(zpay, ZPayProvider): - result = zpay.query_order_status(order_id, timeout=15) - if not result.get("error") and result.get("paid"): - return result - # zpay 查不到或 xorpay:查 PocketBase(notify 后写入) +def _get_linked_email_for_user(pb, user_id: str) -> str | None: + """按 user_id 查询 vip_email_link 关联邮箱。""" + try: + link_rec = pb.collection("vip_email_link").get_list( + 1, 1, {"filter": f'anonymous_user_id = "{user_id}"'} + ) + if link_rec.items: + return link_rec.items[0].get("email") or "" + + if user_id and user_id.startswith("user") and user_id[4:].isdigit(): + link_rec = pb.collection("vip_email_link").get_list( + 1, 1, {"filter": f'user_id = "{user_id}"'} + ) + if link_rec.items: + return link_rec.items[0].get("email") or "" + except Exception: + pass + return None + + +def _uid_has_vip(pb, uid: str, site_id: str = "vip") -> bool: + if not uid: + return False + + sv = pb.collection("site_vip").get_list( + 1, 1, {"filter": f'user_id = "{uid}" && site_id = "{site_id}"'} + ) + if sv.items: + rec = sv.items[0] + expires = rec.get("expires_at") or "" + if not expires: + return True + + from datetime import datetime + + try: + exp_dt = datetime.fromisoformat(expires.replace("Z", "+00:00")) + now = datetime.now(exp_dt.tzinfo) if exp_dt.tzinfo else datetime.now() + return exp_dt.timestamp() >= now.timestamp() + except Exception: + return True + + pm = pb.collection("payments").get_list( + 1, + 1, + {"filter": f'user_id = "{uid}" && type = "vip"', "sort": "-created"}, + ) + return bool(pm.items) + + +@router.get("/check_vip") +async def nomadvip_check_vip(user_id: str = ""): + """Return vip status for a user_id.""" + user_id = (user_id or "").strip() + logging.info("[payjsapi] check_vip user_id=%s", user_id or "(empty)") + if not user_id: + return {"vip": False, "error": "missing user_id"} + try: from ..services.pb import get_pb_client pb = get_pb_client() - records = pb.collection("payments").get_list(1, 1, {"filter": f'order_id = "{order_id}"'}) - return {"paid": len(records.items) > 0} - except Exception as e: - logging.warning(f"nomadvip PocketBase 查询失败: {e}") - return {"paid": False, "error": str(e)} + if not _uid_has_vip(pb, user_id): + logging.info("[payjsapi] check_vip miss user_id=%s vip=False", user_id) + return {"vip": False} + + email = _get_linked_email_for_user(pb, user_id) + if not email: + admin_token = _get_pb_admin_token() + if admin_token and user_id.startswith("user") and user_id[4:].isdigit(): + linked_member = _pb_find_linked_member_from_order(admin_token, user_id) + if linked_member: + linked_user_id, linked_email = linked_member + email = linked_email + _pb_upsert_email_link(admin_token, linked_email, linked_user_id, user_id) + + logging.info( + "[payjsapi] check_vip hit user_id=%s vip=True email=%s", + user_id, + email or "(none)", + ) + return {"vip": True, "email": email} + except Exception as error: + logging.warning("[payjsapi] check_vip failed: %s", error) + return {"vip": False, "error": str(error)} + +def _get_pb_admin_token() -> str | None: + """获取 PocketBase 管理员 token。""" + if not PB_ADMIN_EMAIL or not PB_ADMIN_PASSWORD: + return None + + base = (PB_URL or "").rstrip("/") + for path in [ + "/api/collections/_superusers/auth-with-password", + "/api/admins/auth-with-password", + ]: + try: + response = requests.post( + f"{base}{path}", + json={"identity": PB_ADMIN_EMAIL, "password": PB_ADMIN_PASSWORD}, + timeout=10, + ) + if response.status_code == 200: + return response.json().get("token") + except Exception: + pass + return None + + +def _pb_escape(value: str) -> str: + return str(value).replace("\\", "\\\\").replace('"', '\\"') + + +def _pb_list_email_links(admin_token: str, email: str) -> list[dict]: + base = (PB_URL or "").rstrip("/") + headers = {"Authorization": f"Bearer {admin_token}"} + + for candidate in [email, email.lower()]: + safe_email = _pb_escape(candidate) + response = requests.get( + f"{base}/api/collections/vip_email_link/records", + params={"filter": f'email = "{safe_email}"', "perPage": 1}, + headers=headers, + timeout=10, + ) + if response.status_code != 200: + continue + + items = (response.json() or {}).get("items") or [] + if items: + return items + + return [] + + +def _pb_upsert_email_link( + admin_token: str, + email: str, + pb_user_id: str, + anonymous_user_id: str | None = None, +): + base = (PB_URL or "").rstrip("/") + headers = { + "Authorization": f"Bearer {admin_token}", + "Content-Type": "application/json", + } + items = _pb_list_email_links(admin_token, email) + payload = {"email": email.lower(), "user_id": pb_user_id} + if anonymous_user_id: + payload["anonymous_user_id"] = anonymous_user_id + + if items: + record_id = items[0].get("id") + if record_id: + requests.patch( + f"{base}/api/collections/vip_email_link/records/{record_id}", + json=payload, + headers=headers, + timeout=10, + ) + return + + requests.post( + f"{base}/api/collections/vip_email_link/records", + json=payload, + headers=headers, + timeout=10, + ) + + +def _pb_move_site_vip( + admin_token: str, + from_user_id: str, + to_user_id: str, + site_id: str = "vip", +): + if not from_user_id or not to_user_id or from_user_id == to_user_id: + return + + base = (PB_URL or "").rstrip("/") + safe_from_user_id = _pb_escape(from_user_id) + safe_site_id = _pb_escape(site_id) + response = requests.get( + f"{base}/api/collections/site_vip/records", + params={ + "filter": f'user_id = "{safe_from_user_id}" && site_id = "{safe_site_id}"', + "perPage": 500, + }, + headers={"Authorization": f"Bearer {admin_token}"}, + timeout=10, + ) + if response.status_code != 200: + return + + headers = { + "Authorization": f"Bearer {admin_token}", + "Content-Type": "application/json", + } + items = (response.json() or {}).get("items") or [] + for item in items: + record_id = item.get("id") + if not record_id: + continue + requests.patch( + f"{base}/api/collections/site_vip/records/{record_id}", + json={"user_id": to_user_id}, + headers=headers, + timeout=10, + ) + + +def _pb_latest_vip_payment(admin_token: str, user_id: str) -> dict | None: + base = (PB_URL or "").rstrip("/") + safe_user_id = _pb_escape(user_id) + response = requests.get( + f"{base}/api/collections/payments/records", + params={ + "filter": f'user_id = "{safe_user_id}" && type = "vip"', + "perPage": 1, + "sort": "-created", + }, + headers={"Authorization": f"Bearer {admin_token}"}, + timeout=10, + ) + if response.status_code != 200: + return None + + items = (response.json() or {}).get("items") or [] + return items[0] if items else None + + +def _pb_list_site_vip_by_order( + admin_token: str, + order_id: str, + site_id: str = "vip", +) -> list[dict]: + base = (PB_URL or "").rstrip("/") + safe_order_id = _pb_escape(order_id) + safe_site_id = _pb_escape(site_id) + response = requests.get( + f"{base}/api/collections/site_vip/records", + params={ + "filter": f'order_id = "{safe_order_id}" && site_id = "{safe_site_id}"', + "perPage": 500, + "sort": "-created", + }, + headers={"Authorization": f"Bearer {admin_token}"}, + timeout=10, + ) + if response.status_code != 200: + return [] + + return (response.json() or {}).get("items") or [] + + +def _pb_get_user_email(admin_token: str, user_id: str) -> str | None: + base = (PB_URL or "").rstrip("/") + response = requests.get( + f"{base}/api/collections/users/records/{user_id}", + headers={"Authorization": f"Bearer {admin_token}"}, + timeout=10, + ) + if response.status_code != 200: + return None + + email = (response.json() or {}).get("email") or "" + return email.strip().lower() or None + + +def _pb_find_linked_member_from_order( + admin_token: str, + anonymous_user_id: str, +) -> tuple[str, str] | None: + base = (PB_URL or "").rstrip("/") + safe_user_id = _pb_escape(anonymous_user_id) + response = requests.get( + f"{base}/api/collections/payments/records", + params={ + "filter": f'user_id = "{safe_user_id}" && type = "vip"', + "perPage": 20, + "sort": "-created", + }, + headers={"Authorization": f"Bearer {admin_token}"}, + timeout=10, + ) + if response.status_code != 200: + return None + + items = (response.json() or {}).get("items") or [] + seen_orders = set() + for item in items: + order_id = (item.get("order_id") or "").strip() + if not order_id or order_id in seen_orders: + continue + seen_orders.add(order_id) + + for site_vip in _pb_list_site_vip_by_order(admin_token, order_id): + linked_user_id = (site_vip.get("user_id") or "").strip() + if ( + not linked_user_id + or linked_user_id == anonymous_user_id + or (linked_user_id.startswith("user") and linked_user_id[4:].isdigit()) + ): + continue + + email = _pb_get_user_email(admin_token, linked_user_id) + if email: + return linked_user_id, email + + return None + + +def _pb_ensure_site_vip( + admin_token: str, + from_user_id: str, + to_user_id: str, + site_id: str = "vip", +): + if not from_user_id or not to_user_id or from_user_id == to_user_id: + return + + base = (PB_URL or "").rstrip("/") + safe_from_user_id = _pb_escape(from_user_id) + safe_to_user_id = _pb_escape(to_user_id) + safe_site_id = _pb_escape(site_id) + source_records = requests.get( + f"{base}/api/collections/site_vip/records", + params={ + "filter": f'user_id = "{safe_from_user_id}" && site_id = "{safe_site_id}"', + "perPage": 500, + "sort": "-created", + }, + headers={"Authorization": f"Bearer {admin_token}"}, + timeout=10, + ) + to_records = requests.get( + f"{base}/api/collections/site_vip/records", + params={ + "filter": f'user_id = "{safe_to_user_id}" && site_id = "{safe_site_id}"', + "perPage": 500, + "sort": "-created", + }, + headers={"Authorization": f"Bearer {admin_token}"}, + timeout=10, + ) + payment = _pb_latest_vip_payment(admin_token, from_user_id) + order_id = (payment or {}).get("order_id") or "" + order_records = ( + _pb_list_site_vip_by_order(admin_token, order_id, site_id=site_id) + if order_id + else [] + ) + source_items = (source_records.json() or {}).get("items") or [] if source_records.status_code == 200 else [] + target_items = (to_records.json() or {}).get("items") or [] if to_records.status_code == 200 else [] + canonical = (target_items or source_items or order_records or [None])[0] + headers = { + "Authorization": f"Bearer {admin_token}", + "Content-Type": "application/json", + } + + if canonical and canonical.get("id"): + patch_body = {"user_id": to_user_id, "site_id": site_id} + next_order_id = order_id or (canonical.get("order_id") or "").strip() + if next_order_id: + patch_body["order_id"] = next_order_id + requests.patch( + f"{base}/api/collections/site_vip/records/{canonical['id']}", + json=patch_body, + headers=headers, + timeout=10, + ) + + duplicate_ids = set() + for record in [*target_items, *source_items, *order_records]: + record_id = record.get("id") + if record_id and record_id != canonical["id"]: + duplicate_ids.add(record_id) + for record_id in duplicate_ids: + requests.delete( + f"{base}/api/collections/site_vip/records/{record_id}", + headers={"Authorization": f"Bearer {admin_token}"}, + timeout=10, + ) + return + + if not order_id: + return + + requests.post( + f"{base}/api/collections/site_vip/records", + json={"user_id": to_user_id, "site_id": site_id, "order_id": order_id}, + headers=headers, + timeout=10, + ) + + +@router.post("/check_vip_by_email") +async def nomadvip_check_vip_by_email(item: dict): + """Recover VIP by email/password, optionally with anonymous user_id.""" + email = (item.get("email") or "").strip().lower() + password = (item.get("password") or "").strip() + anonymous_user_id = ( + item.get("user_id") or item.get("anonymousUserId") or "" + ).strip() + logging.info( + "[payjsapi] check_vip_by_email email=%s user_id=%s", + email or "(empty)", + anonymous_user_id or "(empty)", + ) + + if not email or not password: + return {"vip": False, "error": "missing email or password"} + + base = (PB_URL or "").rstrip("/") + login_res = requests.post( + f"{base}/api/collections/users/auth-with-password", + json={"identity": email, "password": password}, + timeout=10, + ) + if login_res.status_code != 200: + try: + error = login_res.json() or {} + except Exception: + error = {} + logging.info( + "[payjsapi] check_vip_by_email login failed status=%s message=%s", + login_res.status_code, + error.get("message", ""), + ) + return { + "vip": False, + "error": error.get("message") or "invalid email or password", + } + + login_data = login_res.json() or {} + token = login_data.get("token") + record = login_data.get("record") or {} + pb_user_id = (record.get("id") or "").strip() + if not token or not pb_user_id: + return {"vip": False, "error": "login failed"} + + admin_token = _get_pb_admin_token() + if not admin_token: + return {"vip": False, "error": "PocketBase admin auth failed"} + + try: + from ..services.pb import get_pb_client + + pb = get_pb_client() + matched_user_id = None + recovered_anonymous_user_id = ( + anonymous_user_id + if anonymous_user_id.startswith("user") and anonymous_user_id[4:].isdigit() + else None + ) + recovery_source = None + + if recovered_anonymous_user_id and _uid_has_vip(pb, recovered_anonymous_user_id): + matched_user_id = recovered_anonymous_user_id + recovery_source = "request_user_id" + else: + items = _pb_list_email_links(admin_token, email) + logging.info( + "[payjsapi] check_vip_by_email vip_email_link email=%s count=%s", + email, + len(items), + ) + for link_record in items: + linked_ids = [] + linked_pb_user_id = (link_record.get("user_id") or "").strip() + linked_anonymous_user_id = ( + link_record.get("anonymous_user_id") or "" + ).strip() + if linked_pb_user_id: + linked_ids.append(linked_pb_user_id) + if linked_anonymous_user_id and linked_anonymous_user_id not in linked_ids: + linked_ids.append(linked_anonymous_user_id) + + for linked_uid in linked_ids: + if not _uid_has_vip(pb, linked_uid): + continue + matched_user_id = linked_uid + if linked_anonymous_user_id.startswith("user") and linked_anonymous_user_id[4:].isdigit(): + recovered_anonymous_user_id = linked_anonymous_user_id + elif linked_uid.startswith("user") and linked_uid[4:].isdigit(): + recovered_anonymous_user_id = linked_uid + recovery_source = "vip_email_link" + break + + if matched_user_id: + break + + if not matched_user_id and _uid_has_vip(pb, pb_user_id): + matched_user_id = pb_user_id + recovered_anonymous_user_id = None + recovery_source = "pb_user_id" + + if not matched_user_id: + logging.info("[payjsapi] check_vip_by_email no vip match for email=%s", email) + return { + "vip": False, + "error": "no linked vip record found; provide the paid user_id if this is a historical anonymous order", + } + + if recovered_anonymous_user_id: + _pb_ensure_site_vip(admin_token, recovered_anonymous_user_id, pb_user_id) + _pb_upsert_email_link( + admin_token, + email, + pb_user_id, + recovered_anonymous_user_id, + ) + else: + _pb_upsert_email_link(admin_token, email, pb_user_id) + + logging.info( + "[payjsapi] check_vip_by_email recovered by %s matched_user_id=%s effectiveUserId=%s anonymousUserId=%s", + recovery_source or "unknown", + matched_user_id, + pb_user_id, + recovered_anonymous_user_id or "(none)", + ) + return { + "vip": True, + "token": token, + "record": record, + "effectiveUserId": pb_user_id, + "anonymousUserId": recovered_anonymous_user_id, + } + except Exception as error: + logging.warning("[payjsapi] check_vip_by_email failed: %s", error) + return {"vip": False, "error": str(error)} + +@router.get("/order_status") +async def nomadvip_order_status(order_id: str = "", out_trade_no: str = ""): + """查询订单支付状态,优先查 ZPAY,失败时回退 PocketBase。""" + order_id = (order_id or out_trade_no or "").strip() + logging.info("[payjsapi] order_status order_id=%s", order_id or "(空)") + if not order_id: + return {"paid": False, "error": "缺少 order_id 或 out_trade_no"} + + try: + from ..services.pb import get_pb_client + + pb = get_pb_client() + records = pb.collection("payments").get_list( + 1, 1, {"filter": f'order_id = "{order_id}"'} + ) + paid = len(records.items) > 0 + logging.info("[payjsapi] order_status PocketBase paid=%s", paid) + if paid: + return {"paid": True, "source": "pocketbase"} + except Exception as error: + logging.warning("[payjsapi] order_status PocketBase failed: %s", error) + + xorpay = get_payment_provider_by_name("xorpay") + xorpay_result = xorpay.query_order_status(order_id, timeout=8) + if xorpay_result.get("error"): + logging.warning( + "[payjsapi] order_status XorPay query failed order_id=%s error=%s", + order_id, + xorpay_result.get("error"), + ) + else: + logging.info( + "[payjsapi] order_status XorPay status=%s paid=%s", + xorpay_result.get("status", ""), + xorpay_result.get("paid", False), + ) + if xorpay_result.get("paid"): + return { + "paid": True, + "source": "xorpay", + "status": xorpay_result.get("status"), + "pay_price": xorpay_result.get("pay_price") or "", + } + + zpay = get_payment_provider_by_name("zpay") + if isinstance(zpay, ZPayProvider): + result = zpay.query_order_status(order_id, timeout=8) + if not result.get("error") and result.get("paid"): + logging.info("[payjsapi] order_status ZPAY paid=True") + return result + + return {"paid": False} @router.post("/xorpay_notify") async def nomadvip_xorpay_notify(request: Request): - """nomadvip 专用:XorPay 异步通知(路径即渠道,不依赖全局 PAYMENT_PROVIDER)""" + """XorPay 异步通知。""" form_data = await request.form() data = {k: v for k, v in form_data.items()} - logging.info(f"nomadvip XorPay 异步通知: {data}") + logging.info("nomadvip XorPay notify: %s", data) order_id = data.get("order_id", "") if order_id in processed_orders: - logging.info(f"nomadvip 订单已处理,跳过: {order_id}") + logging.info("nomadvip order already processed, skip: %s", order_id) return "ok" + provider = get_payment_provider_by_name("xorpay") if not provider.verify_notify(data): - logging.error(f"nomadvip XorPay 验签失败: {data}") + logging.error("nomadvip XorPay verify failed: %s", data) raise HTTPException(status_code=400, detail="sign error") + parsed = provider.parse_notify(data) if parsed.get("trade_status") != "success": return "ok" + pay_price = parsed.get("pay_price", data.get("pay_price", "")) try: - handle_payment_success(order_id, pay_price, "nomadvip-xorpay", use_memos=True, site_id="vip") - logging.info(f"nomadvip 业务处理完成: order_id={order_id}") - except Exception as e: - logging.error(f"nomadvip PocketBase/Memos 处理异常: {e}", exc_info=True) + handle_payment_success( + order_id, + pay_price, + "nomadvip-xorpay", + use_memos=True, + site_id="vip", + ) + logging.info("nomadvip business done: order_id=%s", order_id) + except Exception as error: + logging.error( + "nomadvip PocketBase/Memos failed: %s", error, exc_info=True + ) raise return provider.success_response() @@ -194,31 +841,43 @@ async def nomadvip_xorpay_notify(request: Request): @router.get("/zpay_notify") @router.post("/zpay_notify") async def nomadvip_zpay_notify(request: Request): - """nomadvip 专用:ZPAY 异步通知(路径即渠道,不依赖全局 PAYMENT_PROVIDER)""" + """ZPAY 异步通知。""" provider = get_payment_provider_by_name("zpay") if request.method == "GET": data = dict(request.query_params) else: form_data = await request.form() data = {k: v for k, v in form_data.items()} - logging.info(f"nomadvip ZPAY 异步通知: {data}") + + logging.info("nomadvip ZPAY notify: %s", data) order_id = data.get("out_trade_no", "") if order_id in processed_orders: - logging.info(f"nomadvip 订单已处理,跳过: {order_id}") + logging.info("nomadvip order already processed, skip: %s", order_id) return "success" + if not provider.verify_notify(data): - logging.error(f"nomadvip ZPAY 验签失败: {data}") + logging.error("nomadvip ZPAY verify failed: %s", data) raise HTTPException(status_code=400, detail="sign error") + parsed = provider.parse_notify(data) trade_status = parsed.get("trade_status") or data.get("trade_status", "") - if trade_status != "success" and trade_status != "TRADE_SUCCESS": - logging.info(f"nomadvip 非成功状态,跳过: trade_status={trade_status}") + if trade_status not in ("success", "TRADE_SUCCESS"): + logging.info("nomadvip non-success trade status, skip: %s", trade_status) return "success" + pay_price = parsed.get("pay_price") or data.get("money", "") try: - handle_payment_success(order_id, pay_price, "nomadvip-zpay", use_memos=True, site_id="vip") - logging.info(f"nomadvip 业务处理完成: order_id={order_id}") - except Exception as e: - logging.error(f"nomadvip PocketBase/Memos 处理异常: {e}", exc_info=True) + handle_payment_success( + order_id, + pay_price, + "nomadvip-zpay", + use_memos=True, + site_id="vip", + ) + logging.info("nomadvip business done: order_id=%s", order_id) + except Exception as error: + logging.error( + "nomadvip PocketBase/Memos failed: %s", error, exc_info=True + ) raise return provider.success_response() diff --git a/app/routers/wxh5.py b/app/routers/wxh5.py index 2ccf9a0..08526dd 100644 --- a/app/routers/wxh5.py +++ b/app/routers/wxh5.py @@ -9,7 +9,12 @@ from fastapi import APIRouter, Request, HTTPException from fastapi.responses import RedirectResponse, JSONResponse from ..config import BASE_URL -from ..payment import get_payment_provider, get_payment_provider_by_name +from ..payment import ( + get_payment_provider, + get_payment_provider_by_name, + resolve_provider, + resolve_channel, +) from ..payment.zpay import ZPayProvider from ..services import handle_payment_success, processed_orders @@ -61,10 +66,13 @@ def _sync_wxh5_vip_to_supabase(user_id: str, plan: str): @router.post("/payh5") -async def wxh5_payh5(item: dict): - """wxH5 专用:创建支付订单,支持 per-request provider 覆盖""" - req_provider = (item.get("provider") or "").strip().lower() - provider = get_payment_provider_by_name(req_provider) if req_provider in ("xorpay", "zpay") else get_payment_provider() +async def wxh5_payh5(item: dict, request: Request): + """wxH5 专用:创建支付订单。微信内用 xorpay,PC/手机用 zpay,默认微信支付,支付宝暂不提供""" + req_provider = (item.get("provider") or "").strip().lower() or None + device = (item.get("device") or "pc").lower() + ua = (request.headers.get("user-agent") or "").strip() + provider = resolve_provider(device, req_provider, ua) + channel = resolve_channel(item.get("channel")) plan = (item.get("plan") or "year").lower() total_fee = PLAN_MAP.get(plan, PLAN_MAP["year"])[0] total_fee = int(item.get("total_fee", total_fee)) @@ -81,7 +89,6 @@ async def wxh5_payh5(item: dict): type_map = {"book": "购买书籍", "meetup": "社区报名", "video": "视频解锁", "vip": "VIP会员充值"} name = type_map.get(pay_type, "VIP会员充值") notify_url = _get_notify_path(provider.name) - channel = item.get("channel", "alipay") result = provider.create_order( order_id=order_id, name=name, @@ -113,11 +120,9 @@ async def wxh5_payh5(item: dict): @router.post("/payh5/redirect") async def wxh5_payh5_redirect(item: dict, request: Request): - """wxH5 专用:ZPAY mapi 接口(xorpay 无需 redirect)""" - req_provider = (item.get("provider") or "").strip().lower() - provider = get_payment_provider_by_name("zpay") if req_provider == "zpay" else get_payment_provider() - if not isinstance(provider, ZPayProvider): - raise HTTPException(status_code=400, detail="payh5/redirect 仅支持 zpay") + """wxH5 专用:ZPAY mapi 接口,PC/手机用 zpay,默认微信支付,支付宝暂不提供""" + provider = get_payment_provider_by_name("zpay") + channel = resolve_channel(item.get("channel")) plan = (item.get("plan") or "year").lower() total_fee = PLAN_MAP.get(plan, PLAN_MAP["year"])[0] try: @@ -127,7 +132,6 @@ async def wxh5_payh5_redirect(item: dict, request: Request): total_fee_yuan = f"{total_fee / 100:.2f}" user_id = item.get("user_id", "unknown") pay_type = (item.get("type") or "vip").lower() - channel = item.get("channel", "alipay") device = item.get("device", "pc") client_ip = item.get("client_ip") or (request.client.host if request.client else "127.0.0.1") forwarded = request.headers.get("x-forwarded-for") diff --git a/app/services/__pycache__/payment.cpython-312.pyc b/app/services/__pycache__/payment.cpython-312.pyc index 1178f6e208825fdbbb68e9d734d17311f6d74ac2..791667f84b373c792499f3262f9eda472dce7a37 100644 GIT binary patch delta 1627 zcmZuwU2NM_6u#H4onI%;U!5jR`qHR!yu`Sqlp+nl=zfTiK#eyIZm4vI%*#<>ayOY>NCnkQRN=2eod6M6k-sI8W5Oc zwQv9+K&)gs(*c0uM6|-G&YB+p#7O4PE%+Jt?vxcVAA;-D9IV|za0*Dak73C|xGZB( zp(Im@Rb57=>M;<4K`2RnK+zDjM>cVYJMqW+yi<;?w)YyU-91mpBEP=swjeUvLy^56wmxGhFrAdj(72+nWiKg^4FEZT&< z@YSQF@Zf0rY_^ad&72T7u6%LttE(Fqr|-Y>kvOX4$1;+v^z0~zZxv)Em60?^qEqVG zQCY)13;3N5=GqQRhU692M{NBB*9<+e9>s=+?)Pp|XcYkc=j(`~+wB*(kv6F+tAzm`~vt(q6FwS?dV#W?Pmo$qs(xYfpirPHfN zj@~xETsCw6R2g#hcVU2Z_HAsSBgumA`2)KS`N1;fBgV4dlBDV7i2qP8y}XanJiQF@ ztk5*UE375ClU{N9lQDWF#t@nP{zSj0*Xgb@8qgXSswN#;0q?EabZ7?-*YuuC$2U36RJ70jUsNiHGXMYp delta 1450 zcmZ`&Z)hAv6rZ`x-rer)pZkB8yNlUN(lqHcsWG--Y(Ux|+C*tdvBqGI(z&F2fu!th zlThbU?1z$QtA(i$ZK6^X0x8-M(MT19erQMvZZXm`1dP%o1wVMj;0Fb9cCXhYMIX%U z`!nx1@Avl2Pc63(N(-XM11gc{XVmXw)6%k=7pTe5^@o>ixbsJ3Gi(a7W7`#g9GIoM z6Wi&|;S##@7+e7p{1hylg8Yjoh0L&ucnn?C~8bT- z3bP!xV{QOyHss3Mbzd=44-_>FhhWAA0df~}k>?BxK+($b9MD7LmbV<1k~i%>+50FW zU?CkfeQ_W1A-}~VFu|Z83XR*QOv+TUA{Nsz((DVtn0eOWE~Vb|6Qti4A}fBsC?OjL zm`CApX39pIgFDMgEu;vRD5H|%Z4mSwrT0~mU;IAukIxCC!^jVW>`9rP>mg(OL zOt30^#MXm7>6k@)+orvM-Q;I~fT7WCU7&0@fa0ivcC5g@(qdm_VsB&LKx?qrb1asz znX|*}KxOrRpV#TU6eOLndiFuk9jz+*s(@CJ_3B*FPFIt7C~(ZXeTF&!f?4md_rm_d zcMuMOA*KTi!b+fT#o9x$Hs`tqKZZl5J>`j7bE?rUE4HQo=N{g%iCvqRITqRn+a+4u z+U3vh-56UNxwQJ$M@r{u^C`WqtY4m78~N(RmL}!2i#mS)=EC~)jnxm|-eOO^zkcrP zP5b_5S645-*QCt+8h!=JOU{g{r;-EeDQzFkC+8#mFiHN5bhVqKdAR>XZ%RLKJd;w^ z$i;i#j<0?)egBJ3DVWLlf1*mA(yyVEmQ+!ba)#Tjq%^HxYf@^1m9Sbb2W1jqrJ;Wb{kziMvrD>-Y_(5@Y%(Z;T4BW-YM&Z z&E&VT9}`00uAtlzl#e4n2)pkJjdz5`T(lw=R&uebT%`H|$L^Ij04vIQ_e?{10-j}9 zY1t_n?%K)tq|cD*7M=CWPWfGNR5Zf-XJ4H?^qp!PJ~Edvg6)gWqdBMeciusB{>rq_ zYH#j5_++aDZiNp#bw~m8e1IDB3fJml=4->Pc4h&x)U#k`sb|5(n7YKZQGaMx8)R-n zwhh|JO=T47E}*DDCHzio-vgb*_&rgC2t4*P~KswJaGH-E=v`Fq4>GjYa4$S4wg(z;o z37`N23Mq0RhGJJ7k^1LS3*ArpOo}Ud3gE@CEUN%VEMKAr;T_Ta}jWdrc9^ zfw*kRO=%L&lfr&t1=pu%4lD9iQOe(G?hE@?AcP^ZmNw??{d P-R?Z;nQn9(Oq9$Y#&Bsg delta 329 zcmZqYY~kQN&CAQh00do+)@N2sudry{;1+D~ zE#d>J)8xLz9v`2QpBx`Qc{#I5WD(GX47d2>;}i4p@=Fp+GV}9_PO4pzG?2>(#Knd{;sY}yBjYUwt@{iP7a1JxGUz;*oXnyE0QbF7 A+W-In diff --git a/app/services/payment.py b/app/services/payment.py index 44c54dd..e1af7e2 100644 --- a/app/services/payment.py +++ b/app/services/payment.py @@ -123,7 +123,8 @@ def handle_payment_success( logging.error(f"meetup 支付成功但创建用户失败: {email}") return - print(f"支付成功 [{provider_name}] 用户ID: {user_id},类型: {pay_type},站点: {site_id},订单: {order_id}") + logging.info("[payjsapi] 支付成功 provider=%s user_id=%s pay_type=%s site_id=%s order_id=%s", + provider_name, user_id, pay_type, site_id, order_id) if pay_type == "vip" and use_memos: create_memos_user(user_id, password="12345678") @@ -162,12 +163,19 @@ def handle_payment_success( }) logging.info(f"meetup 完整记录创建(兜底) - user_id: {user_id}") else: - pb_client.collection("payments").create({ - "user_id": user_id, - "type": pay_type, - "order_id": order_id, - "amount": amount_cents, + existing = pb_client.collection("payments").get_list(1, 1, { + "filter": f'order_id = "{order_id}"', }) + if not existing.items: + payload = { + "user_id": user_id, + "type": pay_type, + "order_id": order_id, + "amount": amount_cents, + "total_fee": amount_cents, + "channel": "wxpay", + } + pb_client.collection("payments").create(payload) # 写入 site_vip(站点独立 VIP)。meetup 类型统一写 site_id=meetup effective_site_id = "meetup" if pay_type == "meetup" else site_id @@ -196,6 +204,5 @@ def handle_payment_success( processed_orders.add(order_id) except Exception as e: - print(f"PocketBase 操作失败: {e}") - logging.error(f"PocketBase 操作失败 - order_id: {order_id}, error: {e}") + logging.error("PocketBase 操作失败 order_id=%s error=%s", order_id, e) raise diff --git a/app/services/pb.py b/app/services/pb.py index 6b9e076..0772f0b 100644 --- a/app/services/pb.py +++ b/app/services/pb.py @@ -1,7 +1,10 @@ """PocketBase 客户端(基于 SDK)""" +import logging + from app.config import PB_URL, PB_ADMIN_EMAIL, PB_ADMIN_PASSWORD from app.sdk.pocketbase import PocketBaseSDK +logger = logging.getLogger(__name__) _pb_sdk: PocketBaseSDK | None = None @@ -20,5 +23,5 @@ def get_pb_client(): """获取已认证的 PocketBase 客户端""" sdk = _get_pb_sdk() client = sdk.get_client() - print("PocketBase admin 登录成功") + logger.info("PocketBase admin 登录成功") return client diff --git a/run-with-logs.bat b/run-with-logs.bat new file mode 100644 index 0000000..e460b54 --- /dev/null +++ b/run-with-logs.bat @@ -0,0 +1,4 @@ +@echo off +REM 强制无缓冲输出,确保日志立即显示在终端 +set PYTHONUNBUFFERED=1 +python -u run.py diff --git a/run-with-logs.sh b/run-with-logs.sh new file mode 100644 index 0000000..2d9c563 --- /dev/null +++ b/run-with-logs.sh @@ -0,0 +1,4 @@ +#!/bin/bash +# 强制无缓冲输出,确保日志立即显示在终端 +export PYTHONUNBUFFERED=1 +exec python -u run.py diff --git a/run.py b/run.py index b2e01ae..3dfdd03 100755 --- a/run.py +++ b/run.py @@ -1,11 +1,31 @@ +import os +import sys + +os.environ.setdefault("PYTHONUNBUFFERED", "1") +if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(line_buffering=True) +if hasattr(sys.stderr, "reconfigure"): + sys.stderr.reconfigure(line_buffering=True) + import uvicorn from dotenv import load_dotenv load_dotenv() if __name__ == "__main__": - import os port = int(os.getenv("PORT", "8007")) - uvicorn.run("app.main:app", host="0.0.0.0", port=port, reload=True) - # uvicorn.run("app.main:app", host="0.0.0.0", port=8200, reload=True) - + reload_enabled = os.getenv("UVICORN_RELOAD", "0").strip().lower() in ("1", "true", "yes") + try: + sys.stderr.write(f"\n[payjsapi] startup http://0.0.0.0:{port}\n") + sys.stderr.flush() + except (OSError, ValueError): + pass + + uvicorn.run( + "app.main:app", + host="0.0.0.0", + port=port, + reload=reload_enabled, + log_level="info", + ) +