From f725104c8a3bc6dc9eddc6dc0d6d2700f907c558 Mon Sep 17 00:00:00 2001 From: eric Date: Sun, 8 Mar 2026 01:24:59 -0600 Subject: [PATCH] 's' --- .env.example | 22 + README.md | 24 +- app/__pycache__/__init__.cpython-312.pyc | Bin 134 -> 132 bytes app/__pycache__/config.cpython-312.pyc | Bin 0 -> 1787 bytes app/__pycache__/main.cpython-312.pyc | Bin 11220 -> 15477 bytes app/config.py | 35 ++ app/main.py | 384 ++++++++++-------- app/payment/__init__.py | 16 + .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 489 bytes app/payment/__pycache__/base.cpython-312.pyc | Bin 0 -> 2582 bytes .../__pycache__/factory.cpython-312.pyc | Bin 0 -> 1293 bytes .../__pycache__/xorpay.cpython-312.pyc | Bin 0 -> 4141 bytes app/payment/__pycache__/zpay.cpython-312.pyc | Bin 0 -> 6808 bytes app/payment/base.py | 59 +++ app/payment/factory.py | 39 ++ app/payment/xorpay.py | 82 ++++ app/payment/zpay.py | 153 +++++++ payment_notify.log | 0 requirements.txt | 3 +- 19 files changed, 645 insertions(+), 172 deletions(-) create mode 100644 .env.example create mode 100644 app/__pycache__/config.cpython-312.pyc create mode 100644 app/config.py create mode 100644 app/payment/__init__.py create mode 100644 app/payment/__pycache__/__init__.cpython-312.pyc create mode 100644 app/payment/__pycache__/base.cpython-312.pyc create mode 100644 app/payment/__pycache__/factory.cpython-312.pyc create mode 100644 app/payment/__pycache__/xorpay.cpython-312.pyc create mode 100644 app/payment/__pycache__/zpay.cpython-312.pyc create mode 100644 app/payment/base.py create mode 100644 app/payment/factory.py create mode 100644 app/payment/xorpay.py create mode 100644 app/payment/zpay.py create mode 100644 payment_notify.log diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..f833d04 --- /dev/null +++ b/.env.example @@ -0,0 +1,22 @@ +# 支付渠道:xorpay | zpay +PAYMENT_PROVIDER=xorpay + +# XorPay(默认) +XORPAY_AID=8220 +XORPAY_SECRET=your_secret + +# ZPAY(网关: https://zpayz.cn/) +ZPAY_PID=2025121809351743 +ZPAY_KEY=tpEi7wWIWI2kXiYVTpIG6j7it0mjVW89 + +# 回调根地址 +BASE_URL=https://api.hackrobot.cn + +# PocketBase +PB_URL=https://pocketbase.hackrobot.cn +PB_ADMIN_EMAIL=your@email.com +PB_ADMIN_PASSWORD=your_password + +# Memos +MEMOS_API=https://qun.hackrobot.cn/api/v1/users +MEMOS_TOKEN=your_token diff --git a/README.md b/README.md index cdb11ed..58ee62d 100755 --- a/README.md +++ b/README.md @@ -1,6 +1,28 @@ -# androidh5api +# PayJS API +支付 API 服务,支持多支付渠道切换(XorPay、ZPAY)。 +## 支付渠道 + +| 渠道 | 环境变量 | 说明 | +|------|----------|------| +| XorPay | `PAYMENT_PROVIDER=xorpay` | 默认 | +| ZPAY | `PAYMENT_PROVIDER=zpay` | 需配置 `ZPAY_PID`、`ZPAY_KEY` | + +切换渠道:设置环境变量 `PAYMENT_PROVIDER=xorpay` 或 `zpay`,参考 `.env.example`。 + +## 模块结构 + +``` +app/ +├── config.py # 配置(环境变量) +├── main.py # FastAPI 入口 +└── payment/ + ├── base.py # 支付抽象基类 + ├── xorpay.py # XorPay 实现 + ├── zpay.py # ZPAY 实现(易支付兼容) + └── factory.py # 渠道工厂 +``` ## Getting started diff --git a/app/__pycache__/__init__.cpython-312.pyc b/app/__pycache__/__init__.cpython-312.pyc index 720495a3abc1420fe49998eeb993b19e92c16a65..85355e696b346aae846bd9551f5bf3f3c93a726f 100755 GIT binary patch delta 49 zcmZo;Y+>X%&CAQh00iC%VVM(oj3kwvtzrs_^0QKtOJWKVE3=9d3o>I83kqT;M(P3p DK{*c^ delta 51 zcmZo+Y-8j(&CAQh00hGKZ)8m5F_Ko*FE1|#;iCNf68(b2%B5}Xu5YxuUYT9>os62 z2j@@)4iQF;N|Y1rrLCeiJyw0Fz4x+_53yFNRH=oOQ&G9))L9-DiR6*?KRe(2zW?`W zW`4I=j2If9yS`<*TQKY|WpO^VXFtc6)EKslEnpB!VNeCttSY5KvxZgYG%1Y=qcu?L zL~u&0#%NXc#PQtZbcQWZ_YAoxBWpRGFY2?iMXHt7(t6l3hSp8JTA}{jD`jX(8KAL2 z8JkijXl_uZrj!L*8z)pmihG^K3N-te+Er5v!mLA5odI$&pmvNfeH!OIQG-jwQs z&IaXZN?n0h8&rEq-S7!S_d}lm({QU2qo*ewJclhA5{q5(6 zm8bvw`mi_ZPlkfwc_NBO;$Sj}_wH{#Jb3iwzdt|S;DtO{>b>7vMw9VSc?#h^mE1bV zh_pbmeQH^EipW}pkZo^U3Cc^Ajfn`3b_hS1l(nN?uSd3=MPk7K9-NnZ$z_V9pwBma z+vB@6ygWJz=@IXU_ZCT#L&LX6Y1wg>5b(#QK@cYv@VRnNMildfI|Bo!J-8`8H$di@ z0g5bS7+R3^DWzx>6~)f}XM;&u7o9lCzIL9S=c!d%Op^uLogt}Jflu?Io8n|E0{xRA z5GH~lKbR{!HyDyHWQruW;-&?Ly0?-e8CIz(+Z$-qAB!zU@X4|{f#_Q!Bfe43@IBcO z3Wg#v!XE|YchBp7Qsmw!c+&gY;6SlJ3kBJ9nlT@l4Tj4v>C#LlJw-7QW(F*j!C;sH z1&|Z-sQ?%OTyg_2>kOP`*FlzNVk}$$oX3rFipj-6R*8{QA7;s+nYCoj=TTTP5iTq7 z^kQ604~3Yyz)Tq~0^|=)ypoCdD#+&3fQy$?34naU*%Zo|OWxB=vDuTHQV!TtarizL<;i>X0zdJ5pP(|$ltmJ%^?c`7y>nJ%oz z`U!t5cyb+G=f{F>@Qq{X>yTRs0`e3=$ZEbI>%>x?;Z_8V^0KT^vXrAaF_0lSj%H=u z3N6yyn%u(j>$D)~6sCL+69#OL>9M(v-M_-h8%z8eSsv`6Y_ymYEfIzVuo@mg8ZOrN@P9C_{z5f(z-9 zMx|2yr`D*n$9ha<`~$o4r@c#Z_PwybSFs-Hup9k*-miRL_@w^3zjf7IGcR4y7p`c{ zg-e*N>aJlpVyu@p@$KYRvU-0nUbDPkG1hI)s(RP3V|X$k-3--i;fkf+*;B=LlRHW2 z+K@E7Q0t6Km;>dSJ2$=CzAaxhwdbgrZdCMjQ^#8oL>b#R9ox<=XLVHS9MYj#kv);qeZ6T4ssNIiq!kJmaTB+QPIEtfVYw!s#tu1c<%nt8Tj rsH2Y4+sqbI*V>QNSj)$n;}%Vux?(yqUua0W{z1(&_CPOmqr>_il&a4R literal 0 HcmV?d00001 diff --git a/app/__pycache__/main.cpython-312.pyc b/app/__pycache__/main.cpython-312.pyc index 535956b573d117df578c907300e099490b09a983..2de8ef6cfcaf9e0c924fc8dbc7defe178fcd8b31 100755 GIT binary patch literal 15477 zcmcJ0X>=Rcm1Y%C017)vZ~=FalqfPi!D*KNZAr%3xQD}2?`5U04$LK z6iTsU$}LCaSPmguY0+B@w`H3?lR2TcMDDf|_vzDf3I=nCsVLK&3903rIVZHFKC$Kb zG51!X0FVKr?jQ3czW3@a_bv76-tXS~@GpA3hJf&9-Mj9ajRf&0e2{`FmARECCx~(4 z0KpJ8f*~21hqRGGDznLiRBn?CDP^M|m3tIkrA_Ho*;J(1p0?3YPI=T`jZNd#+O%Gs zO()bTJbJIeW)Sj9kI|cF%MPw46-pGBDa+lFRVqR#`CY5JWX_mdROL_0W4r36;uajzQD_nK1 zm8DSbs%P@=gOn+FhOn(-Rx>8oN~X}YvWH@do>ABum|}P?aamm}p-hH7pvL?RWm_%O zltOJ8)F_#9C|d)&atN9%2~34FW2Vy8*dt@Ao}tn+X7I@y<-qCIE+`sR!-EHmrE!Tef z{`_k{o{?y@S5fZ?q!En##3!>XONe-`Ub6XUbVv!2*U8ZYQO7f$Q20k z`d!`Ko$be+u7RN2@3WG;Vf()QU3=XO<8d8xur4T3Z*v4(d;N^d0}q-`zw?MIh($N> z%vx1^-qQK-^tRTncKd<-dw6B%HX$`Y+S;~vcZa=wZ|m+oe17`5v$d=1@qPQ-c=g`) zz5BZCI6JMF?cVoDdk2hJ*y{?~2ONW5mk(0b|FoNNv3JPTJFFD1cX&MhV|G95?sfYD ze7^YTWL*q2b~`))sfE`S?DI3}$3B+>hy;d8S2j2T&Y;`tvIZ=TmX!@&S0Lc%g{Pqc zX-J%$(5K#lUQkwi9206p&}& zcYEs}AsQ!wsqdX2f-+>VAXrm`%xWtOlNlOe@)XGs5%L6ij66mhmmeaIkyd#}s1mwoPJ_k4 zc-=nBweug(fBMqZkmux9yh#io^%Cp=@!eK>g`*ffEUv5at5D(_t+nB z47i_g3=BNsb+~;^1B1L?I8;yB#h&a+=(Z950tX=)Ca&m9E+`ZF>fs#=GKC^9NgK}U zPwQvshB)1DscQP3ho|XBuF!hU&^S#uEzpFnV0iC0w^f9$=q4dkfJK8Rrr3%RNs!+hi+T9FKON(}RKA~IhoLxqW_;V?kIl#s*r zQeY@XA@zgSKs_Q0%Odh%`dBi`u>2L`=d!G$CJ&R03I+28DncaX*pks*kVO|ErCcUu{;EBbA67917i3)ojB3ublyVU|Ycx~tS-(Osg<%?Yh05$-);!82TA7q7 z!g({r%hD?m=qWSw7;~6rN^zdR04gL(zyMk!>Z}=-h1HAS<6Y)a9wuNO6+*A9*)!%~ zwKPUpEzJVQcghX{M2x6UsJl{W7IUfW;1$Bxz*Cs8csh8}&J&>(kh}Ws>+=_4b5kE( zI~Th;@~iogSLQ$d$l`T*{Q=AMU;bkL?60<3c8v2Xw0);(`iJdK2W4zG({ zg~TYpK+q9%-N^&K;Bh-0C;^-M1AgBf)ixK%KGyQKY)H4=?+XHNY3v>xaPj(9 zU`-%whJ?m9kavMhps*9KO34&nh2q2?3R-11zJvxh{u!bWzCCe{+0zyo2KSJqNL=ZiGhZlDBd4;N-)c;lXM8p#>#TT9Y(el7(f-qROw;iek-( zX2C#IHUpJ6emLRfDmQ+uke6yk^tUxcVa53UWB0#NI$3u4N$!cm(WQs!Z6gpFe{$@}7wscEuITc)!lu~qSU*>A&$Mpq6`kqqp3{4{@=cR{lQypOp=n)f zvbrwzSlqOc(`}qBt+`lvzH;PYpbsn7QWa6C+x6j47j&f|6I~w82PE6~5l&oG68;YB@a=NYG%xMd65^_b0 zu>3W#)>sQ?Xr89mU7?LAoH_FYQ^%&C49%lv;iV*!+84*hDrdJt0-Ga#;0gfaE*46=gwg@l)#E-6 zkPR6@0Cx($9`U;|0=8oVuGCm=hJ6BR-S8K{%rG&l%8Sx*l_hC}Pon7T;nRnsyA%5Q ziKB77b$CZor5Wit8A;|9kL$R?cYU zXlqiNKY9CDNTR@-S)wo`Ol1^ail1^bq0pY}HN&&xk;RR1XoDJK zU?0g{&S-=WA_r27Xkc`#ToS?>MimCE2AD04g+X0PBiWq0Y!YT$7}hMtXufV31EuJa z5SFLLD7uh^iS9B+@zOWxTe-{*mqTcj!W*GaidnmN-XF-R`Khb<9p z*4Z|PwM+0}X6I1rTN0R30UwqLy|VTKFd!Mxp3t7zE2bF?2%}phB6pi>S}83T@u6U4 z5s}sW=ufY`{LHnFFV0O3&rQ8N|NN`-Z#;8t^d}u5)HqWX7eQIId_RP$$CDNj{p@Z7 zXru=F50I&4AKrjr1&<-eOEnOf^~>V+IA zD}|qc2?J)LSO*Z{si*w@BkWU{RiLfIFL2%)j8G8pdmKK20S{>rZ6N!;-%@*rtR12o zMJ&Pqm{j(-JwX@CQvp94M2L#4J;jwBUVq3J3?U~^O<0`j{JY~<-}`UZUw!k&r=#Nb zWzX)89zmoNM2Iw50d4gI6@e~$1ahJE8KU#=ow@qbFR#Bh2?haizKkFba?~348?zF08pa~Obr6w6JVzZ zKv9e0covWjKkP5!W|M8CrVcB(+-cD5%K|EC%y)|6O@Yph@-SAqDScDIt~*+ zz^hJgI*Z?I08eQegQmr4iCr{ArR`)XWrB@B^C0&73jmT~V$QrOMkmbc<5bb`&XI%v zLt6m0jX6p^(hz#2FUz>D!<@aBJKV=<+|yM5tjajL=_{4_3SAmq zHBHyz^Y^||l_UDnoz`(B2e`+citN+$5B_(-#^Fbj(By}4Rc+E_9--$<#pgoN zLoXkW)g(&R#scw@4GB}r@SX*#PO<7rVa<#CM$}2IY4rG4+UjIs=|(p(F$w$X5lkeiJoyTA;SXFhx4`4h>C6&E|s zcf{7mD;j4i*2OEXO=+lm-_*gWhcCOi&PTrtaK&9Sba$NY7OYGM$nS2`iz(g-;HUoCecM;I zZy~onSkDzWz2&TDhEtrOajc5 zF@-*oDT-)A5-a0pWtO$R57%)qGiUYzCeLd>;LBnmJ1Zj zE^S)NY^d~}+laG6mqzI=xVvHEx zB)gz3nR+@#yekJQe@$oy&`!aYU7IW-P7v`A2=l8GA6|Xoy}$NH&3*bCKvDDWe>nfq zImw@OdPpnq6brJHt-Lzu4>~;d9+!)C!4O08rVTx!`3BWWRzMY~ecnKA2Yf)}5mEDe z3?2obcKw&{&V4vJ_u((DeegmE4X#&TnnVCIHFb4z?AjZ@o*3X+)4}c@u&P8% zMxo}(8`7I%9}EG?I|dWsHR2H1kv)wwdmc+={v*5+G-wCF6BP~()!+_OiiR^@pPF8( zg(mfggVU0>3u_)jdS@{j#poPF);s|)@p1q`>>(%s>!#oB0}US>U4Abv%nwR)0EYrc z4B(SK*Kr2CZ@~cT6Ply-lSQt_{vAf>fkOKLv>xDfDQlSk)mRp)V7wF9>B319^~U zgD1?3_6V?1fW%{8hXx>oW3+IZs+u)dM86+5TVvbf=H^K` zSF&-qBWWl&+jqKeba29&FsvQknKYWt9tRtQazdRjuAVWjiyPM^j2nh`%@!7q?;P8C zVF!YfOPgob+#6qW@64Lk_?p(ln(dd5PFL(s6h1t>_e!4WT-*5WvE45|lE||R?@kt! zj+RDiqPwD7CmxR-iaj!UbgFX7$QgGH?*h|bes!{7Z4zRm1wcif5(;&MuJG)Ar|*l> z6HN*2`r)=YV_m|ya(Gv=zUeLRtKP}uU)4VtFQ~nIc=(YkU=Q3kwr^s4qOgH8Sz~Kr zm0bRo;fH}TEJR#bL&C6Tc;~FXXhvTf*ViWVOOyKiWXZ~8zBy^COqxo@zc=>1=$?eh znk*w|%G|sWM(bmzUF$cHhsjr%z8d*Q$IK45U zaV}`FC81O(zrCd)v<&%e0C~u%oyMI_P$&PihaWTy|ETV&%XM1y&AT zx3DZM2L`U8hzJ!yFI?J!)URARHoz~L03)(Jb5wsC5@p&ma)y$`8(5DN8K*LsDak=a z#&RUhPw>Iz08L=o+5%smJ_Ci&4|qu}Q9niI_RodW_JP34tTpAZ1~HnYYg#mxcF9;J znN>=;%vb`T%C#S9I~JoC)`NSH21j89z_47s4B_0nYsl=bv=0Dl;WS_X$gIp|%aL}v zi`YVpl4r_NVp<-NrNp!`R14h?3KvE!^Pil$`tF->Rpi>mH$g^s?(6Ed;AI8SxjRA# z2y?UpjkybI1bZ+0Q_SI!zA*~Nc&IWZJ4Ehk@%uc3NY5e`zb#{TLQ`JJxPlJ1CwqV! z|8VBoyT6un_dA+^yVobWhj?9(h3gk~QCJDiC>E7wtAVF*s)At1XzT1tI3rZEv&0Gl zJzoE`i-oHhDY+}CyCIpTH* zVg;ovTt<8(L^H81VO#|~ zTxFa#)x}kHAZXHjU1dx&*DHqdn4H_#HErnT=x)KYQxn(LOstsJu1f2VjWH(n7+1V; zn%*R|pWHKTXyxeEG>@Oyn9w(%on8Oj(8;0Eri7|y;?P&BCLEM1kM5f2d8_}`{>kzw z<~MyG_FeuV*Zo-HfyWc|2RYkQ)07i5`5EnsxOPQCThCGT3ynnn%J2T5ZT>c3fgL(k z*me*3GqPZcHt0 zm1W;1efL6(l-iKpPWtMAU&|KpYq0C*`sStfz^~$r-mcXxb?T22PO-lZdgUYNs2*|H<^)f88 zyT;6omo&dtE#lWT(uy;ww9`xYH5HMk_;p^W4qD^AzNOrHXM4Bh>d4P5$hCoC^KvNY z2qBpMtJYwD*1;tMyU0p?evx?#j(rN15%w3*Yp66!FUjz3q5dtX&yZfst=YGslKlsa zkTLTLuixhywC1C=8}HG<9R<8rr~^F2f*VcHLs(q*f0T8zm$30e7`=-Tnkm`$AWE@j zkqf_%rKd0wII|$5kSVj15ar;=+N{;GA3+-mSaD!unjg~yZG7&;$rI6{uT)i6jHc0@ zqMb2ew4SDB%W5vJJ-_yqCMW}2WX6)NGhBwwy1F=0{sc$Otu$MpOirE zGn2mEto+QfeKS0KVOBysRMs2_J>_)=;SL0PLhM=BX%;bY1c`_%Zrw~X@ytEQrMAPI z)|oVXMbI43XW${i)=TNjX{ShVTBcdL^f}^XwOouF02mw)3;?Q^vrqzbl^DP>Rw(sd zTWIduLVMR1y1TZ}-?fF|t}TprZ2|6DX+J5dlOc_(2eg5EToAQJ*8I3;X0t-}dH#n|(T|T%)#j9ORNW9|) z4laP-hv14E+|1|$#?Bj@4v#bB0sj_bhs)1`ztO4}C-Vso`zc1h#^^T~UB>7$jQ$a$ zf5PZ|=xJX>R3s-U8ljbct3KQ9D0m6ox8dU-(u?oK(gQk-i?M213lI_Y z4&)<(kB}X}TnM9G812R=fKd%b4?zUxyc3wO$7l#6;eDyVxhGsXgSUeYRMC-#gi{KI zs85tGdpOwNLEBX@(SQ#k!7K$nJ5IAE);&#an3XJ02}8~BPH^d^#7g_LVFyR=SfI)k z?WE*l%PR#{B%Zfo;_*aY;PfwJ^_9x8il4VsF8_qXO+&5W}DBGAU zE*tL~>x&)-Lrd}6B`p`%l~!IfpEpmeiLr@NaLz5FSyVcHaO_~T7mPPWtCLl=7yHil zajUjW&1K2rlCR4RCe4Uy!9o<2j8}|RM0*qY^&`r(i}6uz{~_*C8)xg~ z`g}8eN8^1*xn7nl3QW_%*|MtGWA7h&`%v7tg`>B8b49!@1`EgAa=EUH-9ObkbtvOQ zo2I)(@e0zojicLyyJGF+{|#7S?@u+hZy_hQKiIa8_`I2f{O9Y`?HlEv-?O>BS^h<{ z0`gyMltZ}_8mEIimAkbWGUJ4cfS*DUHW~alinPgH@bjl3I0G&PBfAgkp-h=oCdaZt z5^TEp+XavF0dV%GeznmdTxJw5I>PTSXuEK4$!_O0cDvWlggh?D>+JTUA%_RKgxxN- ze*`G~|~!XnwPgla+dHv!S5e#4Bimf@&5l{O%PL6X-}D)<#~ge3v4 zHYxBIVxrmsavOE{B_VbF3GUXRDg~F+;i?p)%9y=SPB1iFQkQ4R2t+bVLPqgI!yK5N zQZFB5B9-g7=sjDq8CQn!LEkUc;)&HEgLs&W*gywZNjJ-qZW)d zVzdb&V3l}bmc0j}q0|*u@jh!Kur)jaf|;Iu1#{~#!UG`O#bw(uw*#Ymj08Fs$XS&F zTN5uf`&`G^y;$7=k#wOJ4J*7(yaOwa7Qpu*vNw^tX|s+7I}Mfl@pz$~UgT$n{Bw2Z zm~r=*`^6)Pg4%?kZg}Txp&9MEUlnee2*!5C?%`H$N=ikI69?h?AK0S7PF$b_gSCMu zT>~!mH}0Ki(rs;fKLyJ^tX>g9+1$;XPO2 z>O*t%zG`rz%Q4a ztQsFR1H#X(o`RoS2|tv9>nDC+kGq%EAw4}tcos81j}hM-A|Jsmgg==QjAn2_Wnh3e zz%3PfW?WtigxyZK;SMBuYInzueY|!DNWfj;ZNj zD&VAu9+?F>Ns>2Za+1156QuDEgkJcs{WqfGPlWN`h?Y3f@<(FBABn0z5;cFJXj1kU z0-~=m`u75YT>Zb4`X9T8<+Fr*hA4;=1xczPX(}FFnJg|BQ+!y^5~S%ip(JGs(5G-% zHA9%x}2+6m!Q`t=}Ii9jMJ3~x_Ut&Hz@yIrGkMA ziK?1c4sb-?Xx*rDtRX?v-c-_L!PmvB$d+he0fc%>%y|owg>I52E2GW@m}}*PE4F>o z6x;Qx9~>xTK{6>OUxI4|o)(7SXP}Db%qPtw4GGe;AZsC&qs&bLl3Q{@ zRwxuKc%(+0rYu30FUZV5uH+^G3AU;b3KljarIM2+BUK5~oFplMYEyzNU67TNtD?tl z5|Ci~GK@+ASHSq&GNkJh@GPq(+sW99n*^rVu0bri{V*vbEm8Gt0uu1iq$VwrHNb|p!?YxfHBur7 zN#qGx?17A(gbbQYGEpWZ5mI zv}P@o%bhw`fw=(3Bp8jqpBaL=kS=sA6$h4!X(^>RiBlvWLh8EmeVI@TJXPjmN^RCt z8gmJyy?~h)0Y|N4Rj##|qI6K+B<6B4RdBuVeW{S=$5WmiPX$$EuB3|1Rg~UbO_i8y zs73T*succZ@Gpmd1^g@N#r+zp>H=Y|rK)KItgss{2);C=SydJxGc+Zsi)PzWqBT#g~2ul#?E^GBlJZcUrx4 zw}+ygJw^$qI7FZJ(_SB^?K*t;;O?_FdeCS0xPeY}&|^DA`w%sUuRLrdIIY#`^bA=% zjQynD?d6Jutc{^5+U>JjonDc`Mf(OkR5m+6TPd3H25JXV$z?y!kVgkmIP zNG=}yBLw4^FSBMC=7XIgJmS2CFeMhm9E4b&iyewwT1@%KY>MBPDEEGbCyx2FVl9P> z{Bhvki!H@`I zyW4R0wa?T4@ajxtH2srnf4LD!|0FgOKKGX!BmTt;Q>Wkl=-$Wgv>8bTy$dHlxYKgv z(0)$NbM$h>R=;n+GGvEf(CYOLc^Hb*0Zm6|x2eanySu}*pDW7d4|eqS9y@TTlamcH zcDIjH=A=c!$!$(MNCAh_UNl7p!=t+9p`ju4XFMKX^PqLu;k6Fho2`R`&9JKOromxO zdy@8922WUowoC;yY=pnJ4}vguOIvnTp3v5Y_smLUvceRpyQCe_PLmCBvf=vT&BpNo0k5RnsJ%B-f^N1rf#fvoa`s<RBuDU*N1{3R4G+RJf@s-jjs!`h8H=6bRKikN z{0vMkL;L)U+m_KPu#S1V|2kyGVgCj30HlHvK9uqyfkGerg$LzAGG9iQVDp{_LD_sK zLT;yvJKmubx#nU~M>y2EGBG9A^g_DC{Q)q;ID)eJthVeC3`N=FnSxTZ zK#u%c)Ce95rzGOB7{V8zk>=4z7N8;WXrv3!$nt221!&}XG_s&1m?ua<881#*<-&`T z+?N$f2b%mjPf$Lepil}?jKuh_iHswwaB|E=aSADdiXeWf0R#18BzrglEi?zqVfEN4 zUN|@<+GCmB#cww_(U|4H%BbL^a1JwoI57q|U<^_}%t{nAq5y$3&>$@kaaxA9(e^Vm zW#L6>J*wwruN9>>pkOtkE3jFeHow#A%TRie{t+b7U=U=oA9f{JN)1_VU(ym15zVLyRx^lu5S2wamBhw5+49Jmc8))SSyN`A~Ci&=< zpNId0eIP4OVEAL;gaRoSB_6>BN`kZ>B@Mu#K@o2d{4U5IQBak6C>bRe+lA0Bn2$!0 zqj6;AQse+2CX)boE^WE1kq%WPHd3CallXy<$Y!abAWNYxy%La-FFK0S31W{W2VbbBnk~-)2tlRSB_Rp)J=3vv_+4_j>n#uJUvx2RlpW>0be;* zRGTU`q#!IXe4~^V%Art$X-Y0_8QC&HTq9p2qos-RhFDWVy*b*V1*4-2u@;z&>ZdJcV;`o->;w zD4EzUByj_G&^D5g)R#4sDLFcM-;{`B04*jB;*?lZYj7CT4X?kO;~s?RDMzgisrj1BycXc+Nu=z+1FKT~>SK2t=I`Qsj-haPGKs zC%Geg=H_@%KcJ_If(okms-zc#UJ<^Y^MuH}-t_a28njhOkc3$wa`Trrj>^9CTI%8Yj3Uszr|AlIRF2^F~-1R6f3r|Iaw8f*6dxns1didaB%q zj1v9{D#cNt{%w-upwmLiXO+E~IM3a&*zbTQDufO1Ono+U@ul>KuNyMT%aD$|k^cNM zgNt@~yoS41Vl$CXcuLS7GZ)XLU$~L}@Y9*`cW0tE(l;(4r^wW=?o7R$j{NlQXTJy6 z$X{+;y!X8}; zSLsWiv>7iu`KL z4zI`kNU@U!UmRn2PZH4V^0y#lk5R%fz<`s{4C7(^Rgivc4l~nw<9vE* zs?Fd>Cdh*IkM;RTxyr!vY%}y23po|^&-lE^dj&HMGwkCO3_a+y+Gt)IbL#Hh-3NLt zhYvilyN4qg!O!XCh(Qk+(n_HcoTQvuco7S-wHX&0x&jXf!{bnnPBsoLe&o_p=Nn0In#qIM&c z->zNGLa1LFA(0!caQ$T8vke42ggnWWwlA#bbo)9Tu|x;EMtSFZ*p<4=!0 zec2M(b4ydimNdoA#vE+%=A@?WmPUVR|Hyu}s&#T;(#%%uNNPG#wM%11*PEUF`Jx8{!3P|CL<({j35QJo47V>YU%D z?p%WXldeM#(I1y6cdwHEv2nxh71BS|;gJ5*3K@g}UGw9p7v!0D&Oc^WBK7$^{2$x^ z#VDAS^E)v_<@Y??5d9e7#Lpz-(idei@HYqX`3xeFz7xA7xg@no?U+sSG(eH30EC1< z^&>aPWywVqfrSW^|LZOfFmRDEmIn`VW+i|XLIf888;JSb9+Z^I(u8C|SHafkKy00e;1-*(i<>RJ&Fj4OM_38W^yJK{>oiE|y#obv9&vR;N;eUdgGpNqARue#Q+fm*0FnR{0H6Ug`eq)G0F$>AIhPo<4;|`ICFZLU%d`EcPGVHqTX;wvR0h>BD@A#|mGk{n!tpXRs;&>zU0}0~FN+RrB3fQZ*u#18vbcPf#P)hO~JjUmVmrkjk3#%#rk*M9t$2 zq-y!OHt=&$H+Qaca_&HWxH(TyCXNb8`9^FMzj)s!!7-aOBn?T15YSsGHUXg571HI6 zeMwNa;5g?4)=T-FwTy3--9@lpvatRC8zI0xETnr@Hw<^rD9*t?-jPw?<3|hJ=V1<7 zhYjf)=Vv~6=YIJ0yVu@H|LwK(yDx*0KJ&_lfDsT_nTM)Pa6R(K4VfkPK71?v%Bu!H z0`Z3QCx4fI`vRc8d%u4P;lNM+9umwGP{n&8?*IDY-4A{zpdLT6Ir0nI4E9ktGy*E* z3P( zV94`VDZm4n{lGJQM}+!VSzesEBr8yXkDR4)$>^X?|^eK-lXl8>WJe+x7fUNuwA_hZOwCQLyF z-hrD6P$ap^fV>t0o)jk(-U7{AUQ1!jB3$JW`vSGVJ;#Re0~=#0N1zqrWXN;oWuApH z<~bB(-8Wp}Amc$W8PFWx+RMA71@wo6j2VIeP#cdpd1ns~!T8Hd<}j+0^*cRq1*o!l zoK6~U4gtH%1>D9-z#%8KQWTFPjRnjIqF16J2`Eu|iXH}snbqm1d6=GAgh7TzULJ7L z0N~B@0x7e1r<|G>M1JMHoC3;u)Cu6y25-UefD)-h5l%jaDn-YZaMi{{f$j$Uy>1X= z-U)OYyyMO>=VkYF;WF^48`u7(_PyGKv29x2{#x6_x~MF&Yjn}bK33fx?u@J3?-1&j z_Mh9&>eo*mp4`J0wI_)!w+JPxGDMdpi50i!`0N2ul7&hA(zs$Npsi8G1UVXD>o&5w zO-XVyc$6N>F^wTgB-Qm$9dRegnhXe=&_#XGeztUVl5D!AtBOe{_a}87EZKoL7EO?G zg@G?-*LNp%JuKOCTf6AeGb7JTm=fCM(bI9QF}&xtLKW#h7fKbDj%&xX6SnA@L}7hI zmMSirz{eUQij=;5qG)W>__nca3H{O$@^*1KDy)u>;2C2z_0f~js!K>f=TAQAeU^&2-VGc+sY*vP4lwM9zcQ=1KV{%8!(>AG5mlB)J96p=LzG zmhJsQ0WkJpAA8)&_MKo)xLMB->psga8cvddJ7oDZSq<*4iM}Mc96Hey&hC|9MfKl* zrEdT3VF2fE+oiG6)8T};pF`1KHR@|LN-seL!??7=6$@Uo@7N%HWm9M5TTIZH0T ztuFX}Rt-%4*Ne{Q^ChNg{Ac*y?ZkK)q(0PkN#vh*l92opv9qm<#Qu4k1TtSp#MIy^wwWxcTA3+HxU$b`I`qmB1 z3CJ)=s2BC7BBlh~5R{NZ>SrLqO>5hqwQEqQQIsZIwu@UV((* z;@1^jS$c&Uw~yH`pGp)jN$8e__uht!*Y2_IKbN#deX+f<&1`)ux{KwDR!95byi65U zz}ZtQ2XDF#t7uGBEqS{=x|*$M0qcK}Jly?|#EL7&YsPA>4#xsFwEf=z$cIl<)=LIPg{Yx(V+r22srR07bJuf^SLrhODYGfmh8+I&r)( zTJ|l5V$`-;D0+BMBEc&rln-G974ROJ-kK7SlN8|PZ1w7IF^Ex<%5P8!zg+(Z$)6)x diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..f6bda29 --- /dev/null +++ b/app/config.py @@ -0,0 +1,35 @@ +""" +应用配置 +支付渠道可通过 PAYMENT_PROVIDER 切换:xorpay | zpay +""" +import os +from typing import Literal + +PaymentChannel = Literal["xorpay", "zpay"] + +# 当前使用的支付渠道 +PAYMENT_PROVIDER: PaymentChannel = os.getenv("PAYMENT_PROVIDER", "xorpay").lower() + +# XorPay 配置 +XORPAY_AID = os.getenv("XORPAY_AID", "8220") +XORPAY_SECRET = os.getenv("XORPAY_SECRET", "afcacd99570945f88de62624aaa3578e") +XORPAY_CASHIER_URL = os.getenv("XORPAY_CASHIER_URL", "https://xorpay.com/api/cashier") + +# ZPAY 配置(网关: https://zpayz.cn/) +ZPAY_PID = os.getenv("ZPAY_PID", "2025121809351743") +ZPAY_KEY = os.getenv("ZPAY_KEY", "tpEi7wWIWI2kXiYVTpIG6j7it0mjVW89") + +# PocketBase +PB_URL = os.getenv("PB_URL", "https://pocketbase.hackrobot.cn") +PB_ADMIN_EMAIL = os.getenv("PB_ADMIN_EMAIL", "xiaoshuang.eric@gmail.com") +PB_ADMIN_PASSWORD = os.getenv("PB_ADMIN_PASSWORD", "Xiao4669805@") + +# Memos +MEMOS_API = os.getenv("MEMOS_API", "https://qun.hackrobot.cn/api/v1/users") +MEMOS_TOKEN = os.getenv( + "MEMOS_TOKEN", + "eyJhbGciOiJIUzI1NiIsImtpZCI6InYxIiwidHlwIjoiSldUIn0.eyJuYW1lIjoiaGFja3JvYm90IiwiaXNzIjoibWVtb3MiLCJzdWIiOiIxIiwiYXVkIjpbInVzZXIuYWNjZXNzLXRva2VuIl0sImlhdCI6MTc1NzQwNTE0OX0.Idn7kBlxE-CoSOXwWuZ1tHGIRKHAIeDyXSafGS5OHsg", +) + +# 回调地址(用于生成 notify_url) +BASE_URL = os.getenv("BASE_URL", "https://api.hackrobot.cn") diff --git a/app/main.py b/app/main.py index 5919580..3d4ba9d 100755 --- a/app/main.py +++ b/app/main.py @@ -1,15 +1,30 @@ -from fastapi import FastAPI -from fastapi.middleware.cors import CORSMiddleware -from pydantic import BaseModel -import hashlib -import time +""" +支付 API 主入口 +支持多支付渠道(XorPay、ZPAY),通过 PAYMENT_PROVIDER 环境变量切换 +""" +import logging import random import string +import time +from typing import Dict, Any + import requests -from fastapi import Request, HTTPException -import logging +from fastapi import FastAPI, Request, HTTPException +from fastapi.middleware.cors import CORSMiddleware +from pydantic import BaseModel from pocketbase import PocketBase +from .config import ( + PAYMENT_PROVIDER, + BASE_URL, + PB_URL, + PB_ADMIN_EMAIL, + PB_ADMIN_PASSWORD, + MEMOS_API, + MEMOS_TOKEN, +) +from .payment import get_payment_provider + app = FastAPI() app.add_middleware( @@ -20,57 +35,30 @@ app.add_middleware( allow_headers=["*"], ) -# ==================== PocketBase 配置 ==================== -PB_URL = "https://pocketbase.hackrobot.cn" -PB_ADMIN_EMAIL = "xiaoshuang.eric@gmail.com" -PB_ADMIN_PASSWORD = "Xiao4669805@" -# ======================================================== +# 日志 +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(message)s", + handlers=[ + logging.FileHandler("payment_notify.log"), + logging.StreamHandler(), + ], +) + +# 已处理订单(防重复) +processed_orders: set[str] = set() + def get_pb_client(): client = PocketBase(PB_URL) try: client.admins.auth_with_password(PB_ADMIN_EMAIL, PB_ADMIN_PASSWORD) - print("PocketBase admin 登录成功(动态)") + print("PocketBase admin 登录成功") except Exception as e: print("PocketBase admin 登录失败:", e) raise e return client -# 配置日志(添加控制台输出,便于调试) -logging.basicConfig( - level=logging.INFO, - format='%(asctime)s - %(message)s', - handlers=[ - logging.FileHandler('xorpay_notify.log'), - logging.StreamHandler() # 新增:同时输出到控制台 - ] -) - -# 全局已处理订单 -processed_orders = set() - -# ==================== XorPay 配置 ==================== -AID = "8220" -SECRET = "afcacd99570945f88de62624aaa3578e" -# ==================================================== - -# memos 配置 -MEMOS_API = "https://qun.hackrobot.cn/api/v1/users" -MEMOS_TOKEN = "eyJhbGciOiJIUzI1NiIsImtpZCI6InYxIiwidHlwIjoiSldUIn0.eyJuYW1lIjoiaGFja3JvYm90IiwiaXNzIjoibWVtb3MiLCJzdWIiOiIxIiwiYXVkIjpbInVzZXIuYWNjZXNzLXRva2VuIl0sImlhdCI6MTc1NzQwNTE0OX0.Idn7kBlxE-CoSOXwWuZ1tHGIRKHAIeDyXSafGS5OHsg" - -def xorpay_sign(name: str, pay_type: str, price: str, order_id: str, notify_url: str) -> str: - raw = name + pay_type + price + order_id + notify_url + SECRET - return hashlib.md5(raw.encode('utf-8')).hexdigest().lower() - -def verify_xorpay_notify(data: dict) -> bool: - received_sign = data.get("sign", "") - aoid = data.get("aoid", "") - order_id = data.get("order_id", "") - pay_price = data.get("pay_price", "") - pay_time = data.get("pay_time", "") - raw = aoid + order_id + pay_price + pay_time + SECRET - calculated_sign = hashlib.md5(raw.encode('utf-8')).hexdigest().lower() - return calculated_sign == received_sign def create_memos_user(user_id: str, password: str = "123456"): try: @@ -88,11 +76,11 @@ def create_memos_user(user_id: str, password: str = "123456"): "username": username, "password": password, "role": "USER", - "state": "NORMAL" + "state": "NORMAL", } memos_headers = { "Content-Type": "application/json", - "Authorization": f"Bearer {MEMOS_TOKEN}" + "Authorization": f"Bearer {MEMOS_TOKEN}", } response = requests.post(MEMOS_API, json=memos_data, headers=memos_headers, timeout=10) @@ -113,15 +101,89 @@ def create_memos_user(user_id: str, password: str = "123456"): logging.error(f"创建 memos 用户异常 - username: {user_id}, error: {e}") return {"success": False, "error": str(e)} + +def _parse_order_id(order_id: str) -> tuple[str, str]: + """从 order_id 解析 user_id 和 pay_type""" + if "_order_" not in order_id: + return "", "unknown" + prefix = order_id.split("_order_")[0] + if "_" in prefix: + parts = prefix.rsplit("_", 1) + return parts[0], parts[1].lower() + return prefix, "unknown" + + +def _handle_payment_success(order_id: str, pay_price: str, provider_name: str): + """支付成功后的统一业务处理""" + user_id, pay_type = _parse_order_id(order_id) + if not user_id: + return + + print(f"支付成功 [{provider_name}] 用户ID: {user_id},类型: {pay_type},订单: {order_id}") + + if pay_type == "vip": + create_memos_user(user_id) + + if pay_type == "meetup": + logging.info(f"meetup 类型支付成功,不创建 memos 用户 - user_id: {user_id}") + + valid_types = ["vip", "book", "meetup", "video"] + if pay_type not in valid_types: + pay_type = "vip" + + amount_cents = int(float(pay_price or 0) * 100) + + try: + pb_client = get_pb_client() + if pay_type == "meetup": + solan = pb_client.collection("solan") + existing = solan.get_list(1, 1, { + "filter": f'user_id = "{user_id}"', + "sort": "-created", + }) + if existing.items: + record_id = existing.items[0].id + solan.update(record_id, { + "type": "meetup", + "order_id": order_id, + "amount": amount_cents, + }) + logging.info(f"meetup 支付字段补齐成功 - user_id: {user_id}, record_id: {record_id}") + else: + solan.create({ + "user_id": user_id, + "type": "meetup", + "order_id": order_id, + "amount": amount_cents, + }) + 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, + }) + except Exception as e: + print(f"PocketBase 操作失败: {e}") + logging.error(f"PocketBase 操作失败 - order_id: {order_id}, error: {e}") + finally: + processed_orders.add(order_id) + + +# ==================== 支付接口 ==================== + + @app.post("/payh5") async def payh5(item: dict): - print('payh5 item:', item) + """创建 H5 支付订单(根据 PAYMENT_PROVIDER 使用对应渠道)""" + print("payh5 item:", item) total_fee_yuan = f"{item['total_fee'] / 100:.2f}" - user_id = item.get('user_id', 'unknown') - pay_type = item.get('type', 'unknown').lower() + user_id = item.get("user_id", "unknown") + pay_type = item.get("type", "unknown").lower() - random_suffix = ''.join(random.choices(string.hexdigits.lower(), k=8)) + 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}" @@ -133,37 +195,108 @@ async def payh5(item: dict): } name = type_map.get(pay_type, "商品支付") - notify_url = "https://api.hackrobot.cn/xorpay_notify" + provider = get_payment_provider() + notify_path = "/xorpay_notify" if provider.name == "xorpay" else "/zpay_notify" + notify_url = f"{BASE_URL.rstrip('/')}{notify_path}" - params = { - "name": name, - "pay_type": "jsapi", - "price": total_fee_yuan, - "order_id": order_id, - "notify_url": notify_url, - } + # 支付方式:前端可传 channel=alipay|wxpay,ZPAY 使用;XorPay 固定 jsapi + channel = item.get("channel", "alipay") - params["sign"] = xorpay_sign( - params["name"], - params["pay_type"], - params["price"], - params["order_id"], - params["notify_url"] + result = provider.create_order( + order_id=order_id, + name=name, + total_fee_yuan=total_fee_yuan, + pay_type=channel, + notify_url=notify_url, + return_url=item.get("return_url"), + client_ip=item.get("client_ip"), ) - print("生成的参数:", params) - + # 兼容旧版前端:保留 xorpay_params / xorpay_url 字段名 + if provider.name == "xorpay": + return { + "status": "ok", + "xorpay_params": result.get("params", {}), + "xorpay_url": result.get("pay_url", ""), + "provider": provider.name, + } return { "status": "ok", - "xorpay_params": params, - "xorpay_url": f"https://xorpay.com/api/cashier/{AID}" + "params": result.get("params", {}), + "pay_url": result.get("pay_url", ""), + "provider": provider.name, + **{k: v for k, v in result.items() if k not in ("status", "params", "pay_url", "provider")}, } + +@app.post("/xorpay_notify") +async def xorpay_notify(request: Request): + """XorPay 异步通知(POST form)""" + form_data = await request.form() + data = {k: v for k, v in form_data.items()} + + logging.info(f"XorPay 异步通知: {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") + + if not provider.verify_notify(data): + logging.error(f"XorPay 验签失败: {data}") + raise HTTPException(status_code=400, detail="sign error") + + parsed = provider.parse_notify(data) + if parsed.get("trade_status") != "success": + return "ok" + + _handle_payment_success(order_id, parsed.get("pay_price", data.get("pay_price", "")), "xorpay") + return provider.success_response() + + +@app.get("/zpay_notify") +@app.post("/zpay_notify") +async def zpay_notify(request: Request): + """ZPAY 异步通知(GET 或 POST)""" + 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"ZPAY 异步通知: {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") + + if not provider.verify_notify(data): + logging.error(f"ZPAY 验签失败: {data}") + raise HTTPException(status_code=400, detail="sign error") + + parsed = provider.parse_notify(data) + if parsed.get("trade_status") != "success": + return "success" + + _handle_payment_success(order_id, parsed.get("pay_price", data.get("money", "")), "zpay") + return provider.success_response() + + +# ==================== 其他接口 ==================== + + @app.post("/submit_meetup_application") async def submit_meetup_application(item: dict): - print('submit_meetup_application item:', item) + print("submit_meetup_application item:", item) - user_id = item.get('user_id') + user_id = item.get("user_id") if not user_id: raise HTTPException(status_code=400, detail="missing user_id") @@ -196,100 +329,11 @@ async def submit_meetup_application(item: dict): logging.error(f"meetup 申请提交失败 - user_id: {user_id}, error: {e}") raise HTTPException(status_code=500, detail="submit failed") -@app.post("/xorpay_notify") -async def xorpay_notify(request: Request): - form_data = await request.form() - data = {key: value for key, value in form_data.items()} - - logging.info(f"XorPay 异步通知原始数据: {data}") - print("XorPay 异步通知:", data) - - order_id = data.get("order_id", "") - - if order_id in processed_orders: - print(f"订单 {order_id} 已处理过,跳过") - return "ok" - - if not verify_xorpay_notify(data): - print("验签失败") - logging.error(f"验签失败: {data}") - raise HTTPException(status_code=400, detail="sign error") - - if "_order_" not in order_id: - raise HTTPException(status_code=400, detail="invalid order_id format") - - prefix = order_id.split("_order_")[0] - - if "_" in prefix: - parts = prefix.rsplit("_", 1) - user_id = parts[0] - pay_type = parts[1].lower() - else: - user_id = prefix - pay_type = "unknown" - - print(f"支付成功,用户ID: {user_id},类型: {pay_type},订单: {order_id}") - - if pay_type == "vip": - create_memos_user(user_id) - - if pay_type == "meetup": - logging.info(f"meetup 类型支付成功(独立逻辑),不创建 memos 用户 - user_id: {user_id}") - - try: - pb_client = get_pb_client() - - valid_types = ["vip", "book", "meetup", "video"] - if pay_type not in valid_types: - print(f"无效的 pay_type: {pay_type},强制设为 vip") - pay_type = "vip" - - amount_cents = int(float(data.get("pay_price", 0)) * 100) - - if pay_type == "meetup": - solan = pb_client.collection("solan") - # 修复 filter 语法:添加空格和 '=' 操作符(PocketBase 要求) - # 同时添加 sort 以取最新记录(防止同一 user_id 多条) - existing = solan.get_list(1, 1, { - "filter": f'user_id = "{user_id}"', - "sort": "-created" # 按创建时间倒序,取最新一条 - }) - if existing.items: - record_id = existing.items[0].id - solan.update(record_id, { - "type": "meetup", - "order_id": order_id, - "amount": amount_cents, - }) - logging.info(f"meetup 支付字段补齐成功 - user_id: {user_id}, record_id: {record_id}") - else: - solan.create({ - "user_id": user_id, - "type": "meetup", - "order_id": order_id, - "amount": amount_cents, - }) - 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, - }) - - processed_orders.add(order_id) - - except Exception as e: - print(f"PocketBase 操作失败: {e}") - logging.error(f"PocketBase 操作失败 - order_id: {order_id}, error: {e}") - processed_orders.add(order_id) # 防止重复通知 - - return "ok" class CreateUserRequest(BaseModel): - username: str = None - password: str = None + username: str | None = None + password: str | None = None + @app.post("/create_user") async def create_user(item: CreateUserRequest): @@ -298,13 +342,13 @@ async def create_user(item: CreateUserRequest): "username": item.username or f"user{timenew}", "password": item.password or "123456", "role": "USER", - "state": "NORMAL" + "state": "NORMAL", } memos_headers = { "Content-Type": "application/json", - "Authorization": f"Bearer {MEMOS_TOKEN}" + "Authorization": f"Bearer {MEMOS_TOKEN}", } memos_resp = requests.post(MEMOS_API, json=memos_data, headers=memos_headers) memos_result = memos_resp.json() - print('memos_result-', memos_result) - return memos_result \ No newline at end of file + print("memos_result-", memos_result) + return memos_result diff --git a/app/payment/__init__.py b/app/payment/__init__.py new file mode 100644 index 0000000..d6e50aa --- /dev/null +++ b/app/payment/__init__.py @@ -0,0 +1,16 @@ +""" +支付模块 +支持多支付渠道,可通过配置切换 +""" +from .base import PaymentProvider +from .xorpay import XorPayProvider +from .zpay import ZPayProvider +from .factory import get_payment_provider, reset_provider + +__all__ = [ + "PaymentProvider", + "XorPayProvider", + "ZPayProvider", + "get_payment_provider", + "reset_provider", +] diff --git a/app/payment/__pycache__/__init__.cpython-312.pyc b/app/payment/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..822b2c64d392751519a0fba32f27b925c74e3bd5 GIT binary patch literal 489 zcmYjNze~eF7`@Byrq&`jE9g`Nccr-KEFC2jj@ZPKk~HCxigge>NF7=n6er@%sG>{1VxBBZ;FKt`Qt`quuuVLp|DSzTIC3`}MHdkt1?Rkb!#jue3kpP@| T0_03RgN1KSNHR4Mr^(DOF{+xM literal 0 HcmV?d00001 diff --git a/app/payment/__pycache__/base.cpython-312.pyc b/app/payment/__pycache__/base.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..fd323d0b27f1cc3b99aa8872bf4680acfc9ef53c GIT binary patch literal 2582 zcmai0&2JM|5Px2;KjOqr$Oi-p)J*`(sLi2Vs;I35LG7t25;h(-h$KQphseYnaVEmVoe`%4WoL|yyCSZ* zJK~OeA|3}}$j3xvhluEsn43<^N5q?UhdrqTNWQ|U+xhv4!dz}?@VmnC2aB^)`sDrM z?7UB(Ih+4=xHvzVpBpHi9kTbGxU7#|dbTi`ff*+fi8vq+jOd6sC025YjO6ZPMCTBD)81J6Mm(Tp%|F+9 zr|6d4q6c0tyfyIp;H{NFS`T(&U77O(2YSiZ$Dqd4GL}opvJ92=)ZA23 zQ#2ts?9`M#*b^%(ka3cBgqc($u$4~q>}c`oo$l-8x$_FGt z?Md?K0}7vv_VCFh3W%zwmsg~YBos8k$EE(}DzF{6S_hsK50C+3cu9?a1z6Gzo#3;; zaw!_qYJ^x6{BhBw=8_IU$7o)eLbzO1qz$lN3#hWn$GAR8;?gNTp?LuTr>2vV=1a(G zv@eZrD8@ybkTe4+r@;#XKGkWEVa*q`A3bwUpTBQqWpU=BK6<*W>a>t{2KAAndiJKh z=iX0?4{qfjO%(=jo2@@m$Svp#W5rXmdUnXVY>UTkSXZpI+8-)0dY9&K4&x7pEUW%UH2oXRKJ>fSn4u3)%?%2PKMyvFg*msT$%c z^;R%kvAe`w!uVQr5optLY8S6wDqP4`!h{)C7|ZHoXRHaQPmdR7ekt!SSM_jt33J?5 zYP`12Ak40}t-QAf_0!)LGWV>`(8unrwt%$oKU3xV0Cv^?|2Vf&^M9b?Flz%UXH`@#&MdD~k6Zot+iMts|z+gdy5$tX@8p+o1llIV)?H$W44Y24Y zO=}Gga$Zv3Xw)F*BkMOBwaC?>){k6(w6q%a$Tg6bO%r=heqyXasgX2qoaj3FfzgCg zGihj@U?=wG+9&pXA9!3JGR#BP5?>%AWOrot4{u&x2OCXj27@MB5EY`%ZOJLux^jo6 zIt)aA=d+hVn0gC8QwuwrbHcS9x&2d{4Fckw-E&QI!d&ZY_cDsMZ{Um#W#8>Doju3H ztbAeyyI~@S;Hx?LUV-8ev iIvkGYjMKsX<0XzQe~@jD$+o}T?>eABe-rqaApQgC<`3xr literal 0 HcmV?d00001 diff --git a/app/payment/__pycache__/factory.cpython-312.pyc b/app/payment/__pycache__/factory.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3bf336acaff543ca67d37452f6e817e45202ddc4 GIT binary patch literal 1293 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 literal 0 HcmV?d00001 diff --git a/app/payment/base.py b/app/payment/base.py new file mode 100644 index 0000000..cd0679b --- /dev/null +++ b/app/payment/base.py @@ -0,0 +1,59 @@ +""" +支付渠道抽象基类 +定义统一的支付接口,便于切换不同支付渠道(XorPay、ZPAY 等) +""" +from abc import ABC, abstractmethod +from typing import Any, Dict, Optional + + +class PaymentProvider(ABC): + """支付渠道抽象基类""" + + @property + @abstractmethod + def name(self) -> str: + """支付渠道名称""" + pass + + @abstractmethod + def create_order( + self, + *, + order_id: str, + name: str, + total_fee_yuan: str, + pay_type: str, + notify_url: str, + return_url: Optional[str] = None, + client_ip: Optional[str] = None, + extra: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + """ + 创建支付订单 + :return: 包含支付跳转信息等,格式由各渠道自定义 + """ + pass + + @abstractmethod + def verify_notify(self, data: Dict[str, Any]) -> bool: + """验证异步通知签名""" + pass + + @abstractmethod + def parse_notify(self, data: Dict[str, Any]) -> Dict[str, Any]: + """ + 解析异步通知,提取统一字段 + :return: { + "order_id": str, # 商户订单号 + "trade_no": str, # 渠道订单号 + "pay_price": str, # 实付金额(元) + "pay_time": str, # 支付时间 + "trade_status": str, # 支付状态 + } + """ + pass + + @abstractmethod + def success_response(self) -> str: + """异步通知成功时需返回的字符串(如 'ok' 或 'success')""" + pass diff --git a/app/payment/factory.py b/app/payment/factory.py new file mode 100644 index 0000000..4bdcbd7 --- /dev/null +++ b/app/payment/factory.py @@ -0,0 +1,39 @@ +""" +支付渠道工厂 +根据配置返回对应的支付 Provider +""" +from ..config import ( + PAYMENT_PROVIDER, + XORPAY_AID, + XORPAY_SECRET, + XORPAY_CASHIER_URL, + ZPAY_PID, + ZPAY_KEY, +) +from .base import PaymentProvider +from .xorpay import XorPayProvider +from .zpay import ZPayProvider + +_provider_instance: PaymentProvider | None = None + + +def get_payment_provider() -> PaymentProvider: + """获取当前配置的支付渠道实例(单例)""" + global _provider_instance + if _provider_instance is not None: + return _provider_instance + if PAYMENT_PROVIDER == "zpay": + _provider_instance = ZPayProvider(pid=ZPAY_PID, key=ZPAY_KEY) + else: + _provider_instance = XorPayProvider( + aid=XORPAY_AID, + secret=XORPAY_SECRET, + cashier_url=XORPAY_CASHIER_URL, + ) + return _provider_instance + + +def reset_provider(): + """重置 Provider(用于测试或切换渠道)""" + global _provider_instance + _provider_instance = None diff --git a/app/payment/xorpay.py b/app/payment/xorpay.py new file mode 100644 index 0000000..5414b5c --- /dev/null +++ b/app/payment/xorpay.py @@ -0,0 +1,82 @@ +""" +XorPay 支付渠道实现 +文档: https://xorpay.com +""" +import hashlib +from typing import Any, Dict, Optional + +from .base import PaymentProvider + + +class XorPayProvider(PaymentProvider): + """XorPay 支付""" + + def __init__(self, aid: str, secret: str, cashier_url: str = "https://xorpay.com/api/cashier"): + self.aid = aid + self.secret = secret + self.cashier_url = cashier_url.rstrip("/") + + @property + def name(self) -> str: + return "xorpay" + + def _sign(self, name: str, pay_type: str, price: str, order_id: str, notify_url: str) -> str: + raw = name + pay_type + price + order_id + notify_url + self.secret + return hashlib.md5(raw.encode("utf-8")).hexdigest().lower() + + def create_order( + self, + *, + order_id: str, + name: str, + total_fee_yuan: str, + pay_type: str, + notify_url: str, + return_url: Optional[str] = None, + client_ip: Optional[str] = None, + extra: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + # XorPay 使用 jsapi 表示 H5/JSAPI 支付 + channel_pay_type = "jsapi" + params = { + "name": name, + "pay_type": channel_pay_type, + "price": total_fee_yuan, + "order_id": order_id, + "notify_url": notify_url, + } + params["sign"] = self._sign( + params["name"], + params["pay_type"], + params["price"], + params["order_id"], + params["notify_url"], + ) + return { + "status": "ok", + "provider": self.name, + "params": params, + "pay_url": f"{self.cashier_url}/{self.aid}", + } + + def verify_notify(self, data: Dict[str, Any]) -> bool: + received_sign = data.get("sign", "") + aoid = data.get("aoid", "") + order_id = data.get("order_id", "") + pay_price = data.get("pay_price", "") + pay_time = data.get("pay_time", "") + raw = aoid + order_id + pay_price + pay_time + self.secret + calculated = hashlib.md5(raw.encode("utf-8")).hexdigest().lower() + return calculated == received_sign + + def parse_notify(self, data: Dict[str, Any]) -> Dict[str, Any]: + return { + "order_id": data.get("order_id", ""), + "trade_no": data.get("aoid", ""), + "pay_price": data.get("pay_price", ""), + "pay_time": data.get("pay_time", ""), + "trade_status": "success", # XorPay 验签通过即视为成功 + } + + def success_response(self) -> str: + return "ok" diff --git a/app/payment/zpay.py b/app/payment/zpay.py new file mode 100644 index 0000000..ce5f4b9 --- /dev/null +++ b/app/payment/zpay.py @@ -0,0 +1,153 @@ +""" +ZPAY 支付渠道实现 +文档: https://z-pay.cn/doc.html +支持:页面跳转支付、API 接口支付、异步通知验签 +""" +import hashlib +from typing import Any, Dict, Optional + +import requests + +from .base import PaymentProvider + + +class ZPayProvider(PaymentProvider): + """ZPAY 支付(易支付兼容接口)""" + + # API 地址 + SUBMIT_URL = "https://zpayz.cn/submit.php" # 页面跳转 + MAPI_URL = "https://zpayz.cn/mapi.php" # API 接口 + + def __init__(self, pid: str, key: str): + self.pid = pid + self.key = key + + @property + def name(self) -> str: + return "zpay" + + def _md5_sign(self, params: Dict[str, Any], exclude: Optional[set] = None) -> str: + """ + ZPAY MD5 签名算法: + 1. 参数按 ASCII 排序,sign/sign_type/空值不参与 + 2. 拼接为 a=b&c=d + 3. sign = md5(拼接串 + KEY),小写 + """ + exclude = exclude or {"sign", "sign_type"} + filtered = {k: v for k, v in params.items() if k not in exclude and v is not None and v != ""} + sorted_keys = sorted(filtered.keys()) + parts = [f"{k}={filtered[k]}" for k in sorted_keys] + raw = "&".join(parts) + self.key + return hashlib.md5(raw.encode("utf-8")).hexdigest().lower() + + def create_order( + self, + *, + order_id: str, + name: str, + total_fee_yuan: str, + pay_type: str, + notify_url: str, + return_url: Optional[str] = None, + client_ip: Optional[str] = None, + extra: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + # ZPAY: alipay / wxpay(pay_type 可为业务类型或支付方式,此处按支付方式解析) + channel_type = "wxpay" if str(pay_type).lower() in ("wx", "wechat", "wxpay") else "alipay" + params = { + "pid": self.pid, + "type": channel_type, + "out_trade_no": order_id, + "notify_url": notify_url, + "name": name, + "money": total_fee_yuan, + "return_url": return_url or notify_url, + } + if client_ip: + params["clientip"] = client_ip + if extra and extra.get("param"): + params["param"] = str(extra["param"]) + params["sign_type"] = "MD5" + params["sign"] = self._md5_sign(params) + + # 页面跳转方式:返回 form 提交 URL 和参数 + return { + "status": "ok", + "provider": self.name, + "params": params, + "pay_url": self.SUBMIT_URL, + "submit_method": "POST", + } + + def create_order_api( + self, + *, + order_id: str, + name: str, + total_fee_yuan: str, + pay_type: str, + notify_url: str, + return_url: Optional[str] = None, + client_ip: str = "127.0.0.1", + device: str = "pc", + extra: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + """ + ZPAY API 接口支付(返回 payurl/qrcode 等) + 适用于需要二维码或 H5 跳转的场景 + """ + channel_type = "wxpay" if pay_type.lower() in ("wx", "wechat", "wxpay") else "alipay" + params = { + "pid": self.pid, + "type": channel_type, + "out_trade_no": order_id, + "notify_url": notify_url, + "name": name, + "money": total_fee_yuan, + "clientip": client_ip or "127.0.0.1", + "device": device, + } + if return_url: + params["return_url"] = return_url + if extra and extra.get("param"): + params["param"] = str(extra["param"]) + params["sign_type"] = "MD5" + params["sign"] = self._md5_sign(params) + + resp = requests.post(self.MAPI_URL, data=params, timeout=15) + result = resp.json() if resp.headers.get("content-type", "").startswith("application/json") else {} + if isinstance(result, dict) and result.get("code") == 1: + return { + "status": "ok", + "provider": self.name, + "payurl": result.get("payurl"), + "payurl2": result.get("payurl2"), # 微信 H5 + "qrcode": result.get("qrcode"), + "img": result.get("img"), + "O_id": result.get("O_id"), + "trade_no": result.get("trade_no"), + } + return { + "status": "error", + "provider": self.name, + "msg": result.get("msg", resp.text), + } + + def verify_notify(self, data: Dict[str, Any]) -> bool: + received = data.get("sign", "") + calculated = self._md5_sign(data) + return calculated == received + + def parse_notify(self, data: Dict[str, Any]) -> Dict[str, Any]: + # ZPAY 通知:trade_status 为 TRADE_SUCCESS 表示成功 + status = "success" if data.get("trade_status") == "TRADE_SUCCESS" else "pending" + return { + "order_id": data.get("out_trade_no", ""), + "trade_no": data.get("trade_no", ""), + "pay_price": data.get("money", ""), + "pay_time": "", # ZPAY 通知未明确返回 + "trade_status": status, + } + + def success_response(self) -> str: + return "success" diff --git a/payment_notify.log b/payment_notify.log new file mode 100644 index 0000000..e69de29 diff --git a/requirements.txt b/requirements.txt index 27c9242..08c6506 100755 --- a/requirements.txt +++ b/requirements.txt @@ -3,4 +3,5 @@ uvicorn sqlalchemy pydantic python-multipart -pocketbase \ No newline at end of file +pocketbase +requests \ No newline at end of file