From f5d26949c4d54f31eddb1b5b886d90beb1957fb8 Mon Sep 17 00:00:00 2001 From: KiriAky 107 Date: Sat, 28 Mar 2026 22:18:43 +0800 Subject: [PATCH] =?UTF-8?q?=E7=BC=96=E5=86=99=E5=90=8E=E7=AB=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/__init__.py | 1 + .../app/__pycache__/__init__.cpython-312.pyc | Bin 0 -> 135 bytes backend/app/api/__init__.py | 1 + .../api/__pycache__/__init__.cpython-312.pyc | Bin 0 -> 139 bytes .../app/api/__pycache__/api.cpython-312.pyc | Bin 0 -> 872 bytes backend/app/api/api.py | 15 + backend/app/api/endpoints/__init__.py | 1 + .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 149 bytes .../__pycache__/auth.cpython-312.pyc | Bin 0 -> 4423 bytes .../__pycache__/comments.cpython-312.pyc | Bin 0 -> 5585 bytes .../__pycache__/posts.cpython-312.pyc | Bin 0 -> 12103 bytes .../__pycache__/users.cpython-312.pyc | Bin 0 -> 2326 bytes backend/app/api/endpoints/auth.py | 109 +++++++ backend/app/api/endpoints/comments.py | 140 +++++++++ backend/app/api/endpoints/posts.py | 288 ++++++++++++++++++ backend/app/api/endpoints/users.py | 50 +++ backend/app/core/__init__.py | 1 + .../core/__pycache__/__init__.cpython-312.pyc | Bin 0 -> 140 bytes .../core/__pycache__/config.cpython-312.pyc | Bin 0 -> 3142 bytes .../core/__pycache__/database.cpython-312.pyc | Bin 0 -> 1613 bytes .../core/__pycache__/logger.cpython-312.pyc | Bin 0 -> 1430 bytes .../core/__pycache__/security.cpython-312.pyc | Bin 0 -> 3974 bytes backend/app/core/config.py | 67 ++++ backend/app/core/database.py | 39 +++ backend/app/core/logger.py | 42 +++ backend/app/core/security.py | 82 +++++ backend/app/crud/__init__.py | 1 + .../crud/__pycache__/__init__.cpython-312.pyc | Bin 0 -> 140 bytes .../crud/__pycache__/category.cpython-312.pyc | Bin 0 -> 4070 bytes .../crud/__pycache__/comment.cpython-312.pyc | Bin 0 -> 4659 bytes .../app/crud/__pycache__/post.cpython-312.pyc | Bin 0 -> 7810 bytes .../app/crud/__pycache__/tag.cpython-312.pyc | Bin 0 -> 3864 bytes .../app/crud/__pycache__/user.cpython-312.pyc | Bin 0 -> 4356 bytes backend/app/crud/category.py | 72 +++++ backend/app/crud/comment.py | 91 ++++++ backend/app/crud/post.py | 163 ++++++++++ backend/app/crud/tag.py | 66 ++++ backend/app/crud/user.py | 75 +++++ backend/app/models/__init__.py | 1 + .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 142 bytes .../__pycache__/category.cpython-312.pyc | Bin 0 -> 1437 bytes .../__pycache__/comment.cpython-312.pyc | Bin 0 -> 2096 bytes .../models/__pycache__/post.cpython-312.pyc | Bin 0 -> 3860 bytes .../models/__pycache__/tag.cpython-312.pyc | Bin 0 -> 1305 bytes .../models/__pycache__/user.cpython-312.pyc | Bin 0 -> 1991 bytes backend/app/models/category.py | 22 ++ backend/app/models/comment.py | 49 +++ backend/app/models/post.py | 94 ++++++ backend/app/models/tag.py | 21 ++ backend/app/models/user.py | 28 ++ backend/app/schemas/__init__.py | 1 + .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 143 bytes .../schemas/__pycache__/auth.cpython-312.pyc | Bin 0 -> 1960 bytes .../__pycache__/comment.cpython-312.pyc | Bin 0 -> 2828 bytes .../schemas/__pycache__/post.cpython-312.pyc | Bin 0 -> 5755 bytes .../schemas/__pycache__/user.cpython-312.pyc | Bin 0 -> 2665 bytes backend/app/schemas/auth.py | 29 ++ backend/app/schemas/comment.py | 55 ++++ backend/app/schemas/post.py | 115 +++++++ backend/app/schemas/user.py | 50 +++ backend/app/services/__init__.py | 1 + backend/logs/acg_blog_2026-03-28.log | 0 backend/main.py | 76 ++++- 63 files changed, 1841 insertions(+), 5 deletions(-) create mode 100644 backend/app/__init__.py create mode 100644 backend/app/__pycache__/__init__.cpython-312.pyc create mode 100644 backend/app/api/__init__.py create mode 100644 backend/app/api/__pycache__/__init__.cpython-312.pyc create mode 100644 backend/app/api/__pycache__/api.cpython-312.pyc create mode 100644 backend/app/api/api.py create mode 100644 backend/app/api/endpoints/__init__.py create mode 100644 backend/app/api/endpoints/__pycache__/__init__.cpython-312.pyc create mode 100644 backend/app/api/endpoints/__pycache__/auth.cpython-312.pyc create mode 100644 backend/app/api/endpoints/__pycache__/comments.cpython-312.pyc create mode 100644 backend/app/api/endpoints/__pycache__/posts.cpython-312.pyc create mode 100644 backend/app/api/endpoints/__pycache__/users.cpython-312.pyc create mode 100644 backend/app/api/endpoints/auth.py create mode 100644 backend/app/api/endpoints/comments.py create mode 100644 backend/app/api/endpoints/posts.py create mode 100644 backend/app/api/endpoints/users.py create mode 100644 backend/app/core/__init__.py create mode 100644 backend/app/core/__pycache__/__init__.cpython-312.pyc create mode 100644 backend/app/core/__pycache__/config.cpython-312.pyc create mode 100644 backend/app/core/__pycache__/database.cpython-312.pyc create mode 100644 backend/app/core/__pycache__/logger.cpython-312.pyc create mode 100644 backend/app/core/__pycache__/security.cpython-312.pyc create mode 100644 backend/app/core/database.py create mode 100644 backend/app/core/logger.py create mode 100644 backend/app/core/security.py create mode 100644 backend/app/crud/__init__.py create mode 100644 backend/app/crud/__pycache__/__init__.cpython-312.pyc create mode 100644 backend/app/crud/__pycache__/category.cpython-312.pyc create mode 100644 backend/app/crud/__pycache__/comment.cpython-312.pyc create mode 100644 backend/app/crud/__pycache__/post.cpython-312.pyc create mode 100644 backend/app/crud/__pycache__/tag.cpython-312.pyc create mode 100644 backend/app/crud/__pycache__/user.cpython-312.pyc create mode 100644 backend/app/crud/category.py create mode 100644 backend/app/crud/comment.py create mode 100644 backend/app/crud/post.py create mode 100644 backend/app/crud/tag.py create mode 100644 backend/app/crud/user.py create mode 100644 backend/app/models/__init__.py create mode 100644 backend/app/models/__pycache__/__init__.cpython-312.pyc create mode 100644 backend/app/models/__pycache__/category.cpython-312.pyc create mode 100644 backend/app/models/__pycache__/comment.cpython-312.pyc create mode 100644 backend/app/models/__pycache__/post.cpython-312.pyc create mode 100644 backend/app/models/__pycache__/tag.cpython-312.pyc create mode 100644 backend/app/models/__pycache__/user.cpython-312.pyc create mode 100644 backend/app/models/category.py create mode 100644 backend/app/models/comment.py create mode 100644 backend/app/models/post.py create mode 100644 backend/app/models/tag.py create mode 100644 backend/app/models/user.py create mode 100644 backend/app/schemas/__init__.py create mode 100644 backend/app/schemas/__pycache__/__init__.cpython-312.pyc create mode 100644 backend/app/schemas/__pycache__/auth.cpython-312.pyc create mode 100644 backend/app/schemas/__pycache__/comment.cpython-312.pyc create mode 100644 backend/app/schemas/__pycache__/post.cpython-312.pyc create mode 100644 backend/app/schemas/__pycache__/user.cpython-312.pyc create mode 100644 backend/app/schemas/auth.py create mode 100644 backend/app/schemas/comment.py create mode 100644 backend/app/schemas/post.py create mode 100644 backend/app/schemas/user.py create mode 100644 backend/app/services/__init__.py create mode 100644 backend/logs/acg_blog_2026-03-28.log diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..9d8928c --- /dev/null +++ b/backend/app/__init__.py @@ -0,0 +1 @@ +# App module diff --git a/backend/app/__pycache__/__init__.cpython-312.pyc b/backend/app/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..fc45964a8ed2d5751504b60294564c5a80315ab5 GIT binary patch literal 135 zcmX@j%ge<81Row9&*TNtk3k%C@RRg6vnW?p7Ve7s&kUx*Dhp1fn=nIkJQyY-BQ(B})XE4`IV}F*4xL z#Y&bgHZpaIk>NtNED17nvEz4PC7ULDsSwayF2?{*g%{h`KcBMk*~a!~4ZFEsf^5@d zyu}HW3Cb@mNiEW3xy6!LT9R>#wX`_3sQ4CZL4I*b@hy(z{M_8syprN7cKyVHO#L!L zpf0w8qSUm^3O`NWTkP@iDf!9q@weF15{pZKDvCf!;Fe%wL4h8SrrDJMTYpeR2pHMt}vDKR-4XmbqE6+oN`!g>Xjzc_4i^HWN5QtgUBsR)$T ziYEez56p~=j31ep7+JouF)+&AXRx}=VD*q!Y)0l4UX2E>4?scg>kN_?86;<@%-5c& zeT6~o1_KvVM0LK-Or0wX>Uc%2OX**f(!auBfTZfWl-5Nltt$-L2r-TM`ZM*fGH4Zn GA`JlOBev!M literal 0 HcmV?d00001 diff --git a/backend/app/api/api.py b/backend/app/api/api.py new file mode 100644 index 0000000..6252348 --- /dev/null +++ b/backend/app/api/api.py @@ -0,0 +1,15 @@ +""" +API 路由汇总 +""" +from fastapi import APIRouter +from app.api.endpoints import auth, users, posts, comments + +api_router = APIRouter(prefix="/api/v1") + +# 注册各模块路由 +api_router.include_router(auth.router) +api_router.include_router(users.router) +api_router.include_router(posts.router) +api_router.include_router(posts.category_router) +api_router.include_router(posts.tag_router) +api_router.include_router(comments.router) diff --git a/backend/app/api/endpoints/__init__.py b/backend/app/api/endpoints/__init__.py new file mode 100644 index 0000000..ab8cf4e --- /dev/null +++ b/backend/app/api/endpoints/__init__.py @@ -0,0 +1 @@ +# Endpoints module diff --git a/backend/app/api/endpoints/__pycache__/__init__.cpython-312.pyc b/backend/app/api/endpoints/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..99246437307c6a273ddcf0868234e20501ce019a GIT binary patch literal 149 zcmX@j%ge<81XYiYX9@%9#~=I22EeysOg9%MY{a2s?Lme{^m5D}Z7b9a?>fKd| zjogt91v>+UhXhQMPMnq`X6)dkv_qyzXPV)m51r{)H8Xa%Fm2PwhNmb%hlHn|dsi#j z#?JJicSiT;+;i?dd-r_bxvM|h?N$UO_Sv6;LpFr|O$J6Wr8^IL7=$h%33-vkNNhI~ zV!dpL^Kv2H%QHmAb(=zhR|tt-F=Y0dLl&=v%K2_2*?>*qt9L@D?zLMKUXyB}*qWVI606AH0QfVOO=`ShkgNt#_|oxjQ*f+PH65>D}i*O9F-MSbF|ByK?Mv9fxRPqOonMC67ErC zt)N{#f@S4sT1t&PjAq@h$ar7$aCa~OBTE!Uz5tF&;6p(G%YIe%`2zu2QG6;{PAg0c zvA$YbYLnzZSd#S-S=Aqj_`1WLoigq-ZSY6cqfr4KOn+Vvs|gXX55A)Ds=rg|vuwa= z-(e~joTMK6La<`DmZLwfkF2kWSby3;`e4avArif()lwl!YXNeKB95L_48OWbLb31PP?8d|E^=yg$G86&-w*a^-EuR&&k4**BaHa{8tono9RO{a0wfSowg38@rjqBDB>Shx6J^qlandA^?YUYSvQI3bP)K}Cd zA1@`-rACm-(za5M4d;-R9i6i3JKXCt?6C`^m_!#QVW|}o@cQa&Ykf_PExz{FAMS5` zu|u<^t>|8OHBl6FT;UR8>L3z6*mET8VR0cHkPQ?P%cZ4{Yj+@kZL;ZSD$P~`K4M~Jrx@fO&^87nwIf4Hl1;&G1Nr<;@$IcbH}m|864# z-A^l-=4$@aszw(KZX%|6J%5w&kbX6pzPX;%eZmGf&R~y+!Q^Z-D;@bHzv^GaG=#$_ z)68a=25}ZER6|CUS%~-i5{jb(wDcNunx_lQfaVfTd5`-rpsRCG~1%2 zY+sy>tu$5p)ZU2f$ZP6@21ug(; z@F%jCpVrvq`~Kecrd=&9t!?0~iZwPUVG@GaPAWoPn4B{v$*3&$y}+uIeeiXx6cM z#<3>hSQFp*P(XxpDihAisj6o_+B)rQnaOF1xBM-~VenLa!c{+8uzt3va?CYVSO*M5 zD2}&SQ#NGFJHP$a?S|&Yglpqc5-j=FNXJ_TE+2T~sSoOJJon*q31?&c2e%6f*a^7gmjxOc&P2_k7Oh>ReVas=n9%cK>L2s&LCKzTtL`YqqFtwzzz@zzuub1lZdo zq&&#h$~;yG2nXw$*D}9lnydKVHPu76&kWZkdX-zmO_^IzRfe0gH<0DoD1$f#a&d;2 zV9Ruo7(+v1k2Sz+`QL9s+ytLxiWOEj8LRiRD`*HA4KBvUI7y_OL86S71?*r_N!Trw z3aT?BC3rVaSh9GhShrHFC*dM-@q^DlzA`_27Ve~g8*e{Xl#FKHsdiw}TB zFBBxReFHM+F0|SQipoW(^wcgOhkK-9#$B6m*G}hdjPLlTn0w~Lz=;c6rp1zx;9tc` zBe-=Ktj%WllodIO&mS8;Hu9b6+|u}tSs0xdJ~6Uw+EEeT35Y8x8#|hCHmnft%ZByW z3dwncO?gfEOrJ<{E_~tyK$IyYXVMHy^00oFCm*FjxCher#YM^C>P$?y`ONRX_+%(K zd~SYxa_*y_-Mf56e~HO+*OTWbzxZTW6GQOr;_sAoPNx=gLl=^-zI7Gh=V7S_@IyMi z2)aMG5hNsKDI~!e{9-S@@YAWBiYdP0YlU3z22CQ<=5cGH&nJZgKA$EY@xzx&B#1ef z&Ax0c4{l{OO5iAPo>D`&7n3uZ*A;oZJ{?q%B^}dkyD^2 zguh36gn%ELpM`I-Xt%r_?}j1p3{w6MRf=O6=1Z1k_(uX_HYZU1KhVq5=;b@8@(wD0 z#PSULC4%ZdR>V~P%aT6=R6Az9W!V%LAdXn`8TWvD=tpBEe=MG9JeX)a7E?)wNDqPUp~QcdtzgTAtN%uW=yq}XCc gm@2EkkDw)*4OEh%5Ms3emv+M^HuUjow@U;qFB literal 0 HcmV?d00001 diff --git a/backend/app/api/endpoints/__pycache__/comments.cpython-312.pyc b/backend/app/api/endpoints/__pycache__/comments.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e036b18621b5eecdd5db807f207e4fb37c731ffc GIT binary patch literal 5585 zcmcIodu&_P89&$e;kO+>66awjPLt9)v`O3L0ihX7;*_$n1WL-oie$OIH^Ih^t$VM5 z1b0i?j)sY~{IQin%M>)2f*Ky1w$YVoTPq}x_D@WT>|PbA+nThI_J>1@+V!t}=U!ju zWe^kMSogc<@tyBG_nhQ#P0t|uM$e1>+3+UqdfIe;r7~;l&QLd+Arnos^mh*JX61N7d za-NBm#cctboY%(eaYw*GAPwR)td7->5VM9e;2EcYlXFJzR`RPQYvhcxCRGc1GOQVX zmRai=Qqhy^%An51+GibSs8zL2wtTii?K@1eF0O*D;Brz&6f*2B0f~56x zNp6%CB>IsQ$B+3a$=sWW$GK6lm*+wvS1j&Nvbbn1wC>}CWMWj{BuAm*p@<-=l|Di; zg%vBoFrQ+ftWPEcQ7t$|ILN1X9^3>|0>=jsty2gcJkcqI{xH zBQZ&y8;(3DQDSIB7`L`yvn|id#v|-OFk;~gBMZh8EEikC>n@vEdgG7RrYFIKW`vWB zEGLBdh&&fdPEc85v7QOU;(NzIb@5m4FC9JZBYE5?X<{5-4w=DJCCIBE{%rB&U$4D+ zZsCL9FTDH4;`G_V@CaE+(iVmzQOOYa(qkDEBI7Wb@P&Nh8IBDmMq^{)0v^C5Lij5W z!*k<5Abx}hz`R)4vj{~CNQ9yiQvAo{*5RCL7eUK5YYS%QKW^k`rTJ7^F z>Oe0I!c+MU`7GgMQZ=A^{n!T!$4@MtIC|~O^tIQHEWS9gc=|>P@r!D0cHUKC{ zI&~}a4WKG$L3ovPr*&`VP!DYRhxtU53yVVsLgA+Yjzh3d;E4=DKADJ&io%d;qd7Sy z*|2+glc}|Q6Bxy0ARGa49o?QlH|@w?leINwY>m^8&)c?VjoT;umyFiSmYU0zwb{y@ znaZ6xN8?SJv>Nh=)EjOZkjIyGw`Sa}xiVMIY|Gheaup3X^|Xh%g=iNuIWVzzio0n+ zrn;P^B4=?ZI-p=;@`$FHya$<@Z|5zbdq=2;fgGypZY53;-8-n$U64CpMRf0?&R4r| zeis2!LOu;|2Tk4!Vudw;YeJH?Kn>y5yT6E&7ohf=;Uu~mXW#~>uf&cftpV)dHIkE9 zL`+mYk<})6ufe?bLR~}I&G%+D-#c&X%o;ntp1|@Zq;qDqo=>%&%dUp3 zYkS7EeI}M|_Gg;?^RB+Et#69CWG%mJuejUbQM; zo3(Gv*tY^lmSYmNG7}E~fzsBa1Cs-D70om0Y)fyZrFX8`|Mv$!8TsVNIp^aSsV8zq z`$T{KK4iM@zj>hXJ;d#NCG@=`;28K&LmxrBMff|Z-*#Ied%lhEcTwltdn%#uk%jQz zPkm(FhVxwnmiJ>hZf<$LFyXL`4oZfVLbwpDgK2~?@yTIG8W!W5(W?rN4Zu^V>RW~( zKvg9I5!Gi}Q=p|%UM%t#tr6^6LR<;3;QF;brO_}E0UCe)`O&mY@i6G zU2W#Oq0^J_2!8>gQUpd`Q^wge(>d>K&sy3i9=K$*e|2;NFmoS>0z!IDzb+9jG_L0YR*tUZ)jVlq4pLcUChJ)#7-)vx6Bx( z({s+&i&R_ASUpEo=Y7c9aXaq@#XG_#_<%!IJ-Z<)>bZwH-Q5M*3spoo>cu{0zKJo35sRTKtFjEY7z*`}$4RTg42x-mNB9r(ok*VTlJ?;o37SUQN z)L`lLxEmt?aX*Kru5cN_Y_l*mz4U4UKK(rYTc8BY6s01}RzL;UPEO=7sEpqLRD2hh z$JZC!X%%uv1v&D$ia{e_nucSvaEL1UMTGdF@mZ|(8VCg;Ci!go4iRE>WeuK;!E-V? zZ}4R)-yG$;Oj(qZO+~?HR9n7I+5d=cD(?R#y#Mcl{lBfJiMXI6dbUy*^tCv@l>piA z+azg&K{gQ%1|{uq2rhcb2#+UV<#xv7n*fi`E6D-ZrDh@g6c-N(&1zVIcQUWX0tfE$ zEln!JiufOiq846l`C6`2%NIKNLTHm!6Fk=}aPU_}L>%Msc_LYh^~ygLN+`te%D*fm z+{}2)ml6rLHiE`}}h7Baz9JD03X&i@Q1sTKL)- z>>cPI?CT%o@dCe%Zh%Dc4!EP+4TPJDao^|nLmfWJgl9qKNrE7jH5!7t#UMEOKSTao z8k*4DKp?&}A)@JX)bKgl`8m4lpL+YryJw!5`Qb%<#{`pSkVbd3da`=XZn-+QULZkn3g_0YxYA1~``2qtGP%aicF zVld}vc!Rw%M_!9_I^?YU0_)r$k*1f(YjEXm?5<|AW@_KG_3UHWuE9*#;6%+lu^*f| zYvyX2=N&D}bOYhY+3WHoyswzczQAQ{fo_Ju*NSs8wYzyA< RNZ^ev?1jmOEI$Yf@IQ+{7tR0x literal 0 HcmV?d00001 diff --git a/backend/app/api/endpoints/__pycache__/posts.cpython-312.pyc b/backend/app/api/endpoints/__pycache__/posts.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..318a68cae9bcac3869c8a6a8ccd824ceb8ad2547 GIT binary patch literal 12103 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 TrG}gg33BA|9B2q2i8lEkK^kUG literal 0 HcmV?d00001 diff --git a/backend/app/api/endpoints/__pycache__/users.cpython-312.pyc b/backend/app/api/endpoints/__pycache__/users.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9d1e4fa5f390ad9ca41e22fbc6c79d975b27e3ec GIT binary patch literal 2326 zcmdT_U2GIp6ux(6cJ{x!+X9xZk|k7Oo9x!Yq6twVpZs7~_)PhC_ zbI-Z=+;i^v&Y5rheh&hA^T%(ryAFhYVv8!t6=vZnjL-$7q9jtWDr9j^ND4VIDdwc4 zlyf8Keq8?yG`* zS292Xne|l%mS)vMoRi+_E;#8RzN_FBqXDe?NN7M%{l^_wZ4pY=gPo0)#j@#N?9>q# zw}O9hYi8tRAY5Hkb`?z31n5$LCI6kF@u^5ScssQR&72(|pXdV70152i`c&Xvf{DU!UZM+M8%rxV&^9>{8Go9`>C zDT73@ibW+`7#t*YSZ-x<-DEkoodff7k&*%J zpd}fpL480ZOw{QZ6-3S9*jG8`ENiL{r zG=i$>I|kr6BqDTPxFDWzIM87{g7vTgV6@4o!VqO#RoT+43X@_L7DwUaOXz8Idc!sG z4IGusM%Z`n?Df*Q^QAA}DZO>Z4)pF_KUva`FdYsZ8@nQ=?vZ_J{P zL>Ak2ZR>v?UcZMHG9+#EA4sKNhqU%XQsC42fiD)cyrK8=Q}N=E6_Wca=`qQbE%_PVv8r-=R;5YD5)2IXM4U1#z7?d( zBSI#)LI}%C=>3{U7>x)VN}U`^P7bBHOP|l3|Lm_&>V+!tlY{B3sS?G6N-?@<2n^DC z7>4WmpwJ@rbE?}SQMZjH*_K!yUR8=}=88+8qK~lSUKlJ%%MTPR7t2XL1qCV6bxgp8kz@^(sGtcY2( zl>LvQY|o=?cnpTF=o-tZC~6_CD3)^|1-+-JQI^qVSYZ9n3PPKR>*?1>E~Ur0HC7pv z6@}$zI@bDa1#eX$E-O$H*9rUyG-HUevatd+x&0%sK_*lC4(T68@FxHYq6J57L$wR-()7|J5XcwP{&^t+7k_8Ii-cWBEUwBZhFyMvzi z!CilG)A*+GrW@|Ws9ctj;5ylKr0G=WSj%YB436DuX`9;IIn%Op9=ULEtm8hL7DOb( z%K~uaH7gIy%V<@2Djc6_Xq|WXaPVd*Toz&e-XFZr)|IosmDg3zzBt~*E=zEe7nknX MI?wK)TOf)*0l%U(YybcN literal 0 HcmV?d00001 diff --git a/backend/app/api/endpoints/auth.py b/backend/app/api/endpoints/auth.py new file mode 100644 index 0000000..18621f1 --- /dev/null +++ b/backend/app/api/endpoints/auth.py @@ -0,0 +1,109 @@ +""" +认证 API 接口 +""" +from fastapi import APIRouter, HTTPException, status +from app.schemas.auth import LoginRequest, RegisterRequest, TokenResponse, RefreshTokenRequest +from app.schemas.user import UserPublic +from app.crud.user import user_crud +from app.core.security import ( + create_access_token, + create_refresh_token, + decode_token, +) +from app.core.logger import app_logger + +router = APIRouter(prefix="/auth", tags=["认证"]) + + +@router.post("/register", response_model=UserPublic, status_code=status.HTTP_201_CREATED) +async def register(request: RegisterRequest): + """用户注册""" + # 检查用户名是否存在 + if await user_crud.get_by_username(request.username): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="用户名已存在" + ) + + # 检查邮箱是否存在 + if await user_crud.get_by_email(request.email): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="邮箱已被注册" + ) + + # 创建用户 + user = await user_crud.create( + username=request.username, + email=request.email, + password=request.password + ) + + app_logger.info(f"New user registered: {user.username}") + return user + + +@router.post("/login", response_model=TokenResponse) +async def login(login_data: LoginRequest): + """用户登录""" + username_or_email = login_data.username + password = login_data.password + + # 验证用户 + user = await user_crud.authenticate(username_or_email, password) + if not user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="用户名或密码错误", + headers={"WWW-Authenticate": "Bearer"}, + ) + + if not user.is_active: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="用户已被禁用" + ) + + # 生成令牌 + token_data = {"sub": str(user.id), "username": user.username} + access_token = create_access_token(token_data) + refresh_token = create_refresh_token(token_data) + + app_logger.info(f"User logged in: {user.username}") + return TokenResponse( + access_token=access_token, + refresh_token=refresh_token, + token_type="bearer" + ) + + +@router.post("/refresh", response_model=TokenResponse) +async def refresh_token(request: RefreshTokenRequest): + """刷新令牌""" + payload = decode_token(request.refresh_token) + + if payload.get("type") != "refresh": + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="无效的刷新令牌" + ) + + user_id = payload.get("sub") + username = payload.get("username") + + # 生成新令牌 + token_data = {"sub": user_id, "username": username} + access_token = create_access_token(token_data) + refresh_token = create_refresh_token(token_data) + + return TokenResponse( + access_token=access_token, + refresh_token=refresh_token, + token_type="bearer" + ) + + +@router.post("/logout") +async def logout(): + """用户登出(前端清除令牌即可)""" + return {"message": "登出成功"} diff --git a/backend/app/api/endpoints/comments.py b/backend/app/api/endpoints/comments.py new file mode 100644 index 0000000..6aa03e2 --- /dev/null +++ b/backend/app/api/endpoints/comments.py @@ -0,0 +1,140 @@ +""" +评论 API 接口 +""" +from fastapi import APIRouter, HTTPException, status, Depends, Query +from app.schemas.comment import ( + CommentCreate, + CommentUpdate, + CommentResponse, + CommentListResponse +) +from app.crud.comment import comment_crud +from app.crud.post import post_crud +from app.core.security import get_current_user_id +from app.core.logger import app_logger + +router = APIRouter(prefix="/comments", tags=["评论"]) + + +@router.get("/post/{post_id}", response_model=CommentListResponse) +async def get_post_comments( + post_id: str, + page: int = Query(1, ge=1, description="页码"), + page_size: int = Query(20, ge=1, le=100, description="每页数量"), + approved_only: bool = Query(True, description="仅显示已审核评论") +): + """获取文章的所有评论""" + # 检查文章是否存在 + post = await post_crud.get_by_id(post_id) + if not post: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="文章不存在" + ) + + comments, total = await comment_crud.get_by_post( + post_id=post_id, + page=page, + page_size=page_size, + approved_only=approved_only + ) + + return CommentListResponse( + items=comments, + total=total, + page=page, + page_size=page_size + ) + + +@router.post("", response_model=CommentResponse, status_code=status.HTTP_201_CREATED) +async def create_comment( + comment_data: CommentCreate, + user_id: str = Depends(get_current_user_id) +): + """创建评论""" + # 检查文章是否存在 + post = await post_crud.get_by_id(comment_data.post_id) + if not post: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="文章不存在" + ) + + # 如果是回复,检查父评论是否存在 + if comment_data.parent_id: + parent = await comment_crud.get_by_id(comment_data.parent_id) + if not parent: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="父评论不存在" + ) + if str(parent.post_id) != comment_data.post_id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="父评论不属于该文章" + ) + + comment = await comment_crud.create( + content=comment_data.content, + author_id=user_id, + post_id=comment_data.post_id, + parent_id=comment_data.parent_id + ) + + app_logger.info(f"Comment created on post {comment_data.post_id} by user {user_id}") + return comment + + +@router.put("/{comment_id}", response_model=CommentResponse) +async def update_comment( + comment_id: str, + comment_data: CommentUpdate, + user_id: str = Depends(get_current_user_id) +): + """更新评论""" + comment = await comment_crud.get_by_id(comment_id) + if not comment: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="评论不存在" + ) + + # 检查权限(只能修改自己的评论) + if str(comment.author_id) != user_id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="无权限修改此评论" + ) + + updated_comment = await comment_crud.update( + comment_id, + **comment_data.model_dump(exclude_unset=True) + ) + + app_logger.info(f"Comment updated: {comment_id}") + return updated_comment + + +@router.delete("/{comment_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_comment( + comment_id: str, + user_id: str = Depends(get_current_user_id) +): + """删除评论""" + comment = await comment_crud.get_by_id(comment_id) + if not comment: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="评论不存在" + ) + + # 检查权限(只能删除自己的评论) + if str(comment.author_id) != user_id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="无权限删除此评论" + ) + + await comment_crud.delete(comment_id) + app_logger.info(f"Comment deleted: {comment_id}") diff --git a/backend/app/api/endpoints/posts.py b/backend/app/api/endpoints/posts.py new file mode 100644 index 0000000..d182447 --- /dev/null +++ b/backend/app/api/endpoints/posts.py @@ -0,0 +1,288 @@ +""" +文章 API 接口 +""" +import math +from typing import Optional +from fastapi import APIRouter, HTTPException, status, Depends, Query +from app.schemas.post import ( + PostCreate, + PostUpdate, + PostResponse, + PostListResponse, + TagCreate, + TagResponse, + CategoryCreate, + CategoryResponse +) +from app.schemas.post import AuthorResponse +from app.crud.post import post_crud +from app.crud.category import category_crud +from app.crud.tag import tag_crud +from app.crud.user import user_crud +from app.core.security import get_current_user_id +from app.core.logger import app_logger + +router = APIRouter(prefix="/posts", tags=["文章"]) +category_router = APIRouter(prefix="/categories", tags=["分类"]) +tag_router = APIRouter(prefix="/tags", tags=["标签"]) + + +# ==================== 文章接口 ==================== + +@router.get("", response_model=PostListResponse) +async def get_posts( + page: int = Query(1, ge=1, description="页码"), + page_size: int = Query(10, ge=1, le=100, description="每页数量"), + status: Optional[str] = Query("published", description="文章状态"), + category_id: Optional[str] = Query(None, description="分类ID"), + tag_id: Optional[str] = Query(None, description="标签ID") +): + """获取文章列表""" + posts, total = await post_crud.get_all( + page=page, + page_size=page_size, + status=status, + category_id=category_id, + tag_id=tag_id + ) + + return PostListResponse( + items=posts, + total=total, + page=page, + page_size=page_size, + total_pages=math.ceil(total / page_size) if total > 0 else 0 + ) + + +@router.get("/hot", response_model=list[PostResponse]) +async def get_hot_posts(limit: int = Query(10, ge=1, le=50)): + """获取热门文章""" + posts = await post_crud.get_hot_posts(limit=limit) + return posts + + +@router.get("/recent", response_model=list[PostResponse]) +async def get_recent_posts(limit: int = Query(10, ge=1, le=50)): + """获取最新文章""" + posts = await post_crud.get_recent_posts(limit=limit) + return posts + + +@router.get("/{post_id}", response_model=PostResponse) +async def get_post(post_id: str): + """获取文章详情""" + post = await post_crud.get_by_id(post_id) + if not post: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="文章不存在" + ) + + # 增加浏览量 + await post_crud.increment_view_count(post_id) + + return post + + +@router.post("", response_model=PostResponse, status_code=status.HTTP_201_CREATED) +async def create_post( + post_data: PostCreate, + user_id: str = Depends(get_current_user_id) +): + """创建文章""" + # 检查 slug 是否已存在 + if await post_crud.get_by_slug(post_data.slug): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="URL别名已存在" + ) + + post = await post_crud.create( + author_id=user_id, + **post_data.model_dump() + ) + + app_logger.info(f"Post created: {post.title} by user {user_id}") + return post + + +@router.put("/{post_id}", response_model=PostResponse) +async def update_post( + post_id: str, + post_data: PostUpdate, + user_id: str = Depends(get_current_user_id) +): + """更新文章""" + post = await post_crud.get_by_id(post_id) + if not post: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="文章不存在" + ) + + # 检查权限 + if str(post.author_id) != user_id: + is_admin = await user_crud.is_superuser(user_id) + if not is_admin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="无权限修改此文章" + ) + + updated_post = await post_crud.update( + post_id, + **post_data.model_dump(exclude_unset=True) + ) + + app_logger.info(f"Post updated: {updated_post.title}") + return updated_post + + +@router.delete("/{post_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_post( + post_id: str, + user_id: str = Depends(get_current_user_id) +): + """删除文章""" + post = await post_crud.get_by_id(post_id) + if not post: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="文章不存在" + ) + + # 检查权限 + if str(post.author_id) != user_id: + is_admin = await user_crud.is_superuser(user_id) + if not is_admin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="无权限删除此文章" + ) + + await post_crud.delete(post_id) + app_logger.info(f"Post deleted: {post_id}") + + +# ==================== 分类接口 ==================== + +@category_router.get("", response_model=list[CategoryResponse]) +async def get_categories(): + """获取所有分类""" + return await category_crud.get_all() + + +@category_router.get("/{category_id}", response_model=CategoryResponse) +async def get_category(category_id: str): + """获取分类详情""" + category = await category_crud.get_by_id(category_id) + if not category: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="分类不存在" + ) + return category + + +@category_router.post("", response_model=CategoryResponse, status_code=status.HTTP_201_CREATED) +async def create_category( + category_data: CategoryCreate, + user_id: str = Depends(get_current_user_id) +): + """创建分类""" + # 检查权限 + is_admin = await user_crud.is_superuser(user_id) + if not is_admin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="需要管理员权限" + ) + + # 检查 slug 是否已存在 + if await category_crud.get_by_slug(category_data.slug): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="URL别名已存在" + ) + + category = await category_crud.create(**category_data.model_dump()) + app_logger.info(f"Category created: {category.name}") + return category + + +@category_router.put("/{category_id}", response_model=CategoryResponse) +async def update_category( + category_id: str, + category_data: CategoryCreate, + user_id: str = Depends(get_current_user_id) +): + """更新分类""" + is_admin = await user_crud.is_superuser(user_id) + if not is_admin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="需要管理员权限" + ) + + category = await category_crud.update(category_id, **category_data.model_dump()) + if not category: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="分类不存在" + ) + return category + + +@category_router.delete("/{category_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_category( + category_id: str, + user_id: str = Depends(get_current_user_id) +): + """删除分类""" + is_admin = await user_crud.is_superuser(user_id) + if not is_admin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="需要管理员权限" + ) + + await category_crud.delete(category_id) + + +# ==================== 标签接口 ==================== + +@tag_router.get("", response_model=list[TagResponse]) +async def get_tags(): + """获取所有标签""" + return await tag_crud.get_all() + + +@tag_router.get("/{tag_id}", response_model=TagResponse) +async def get_tag(tag_id: str): + """获取标签详情""" + tag = await tag_crud.get_by_id(tag_id) + if not tag: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="标签不存在" + ) + return tag + + +@tag_router.post("", response_model=TagResponse, status_code=status.HTTP_201_CREATED) +async def create_tag( + tag_data: TagCreate, + user_id: str = Depends(get_current_user_id) +): + """创建标签""" + # 检查 slug 是否已存在 + if await tag_crud.get_by_slug(tag_data.slug): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="URL别名已存在" + ) + + tag = await tag_crud.create(**tag_data.model_dump()) + app_logger.info(f"Tag created: {tag.name}") + return tag diff --git a/backend/app/api/endpoints/users.py b/backend/app/api/endpoints/users.py new file mode 100644 index 0000000..9e0d284 --- /dev/null +++ b/backend/app/api/endpoints/users.py @@ -0,0 +1,50 @@ +""" +用户 API 接口 +""" +from fastapi import APIRouter, HTTPException, status, Depends +from app.schemas.user import UserPublic, UserUpdate +from app.crud.user import user_crud +from app.core.security import get_current_user_id +from app.core.logger import app_logger + +router = APIRouter(prefix="/users", tags=["用户"]) + + +@router.get("/me", response_model=UserPublic) +async def get_current_user(user_id: str = Depends(get_current_user_id)): + """获取当前用户信息""" + user = await user_crud.get_by_id(user_id) + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="用户不存在" + ) + return user + + +@router.put("/me", response_model=UserPublic) +async def update_current_user( + user_update: UserUpdate, + user_id: str = Depends(get_current_user_id) +): + """更新当前用户信息""" + user = await user_crud.update(user_id, **user_update.model_dump(exclude_unset=True)) + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="用户不存在" + ) + app_logger.info(f"User updated: {user.username}") + return user + + +@router.get("/{user_id}", response_model=UserPublic) +async def get_user(user_id: str): + """获取指定用户信息""" + user = await user_crud.get_by_id(user_id) + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="用户不存在" + ) + return user diff --git a/backend/app/core/__init__.py b/backend/app/core/__init__.py new file mode 100644 index 0000000..3e83c63 --- /dev/null +++ b/backend/app/core/__init__.py @@ -0,0 +1 @@ +# Core module diff --git a/backend/app/core/__pycache__/__init__.cpython-312.pyc b/backend/app/core/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4e0132bc10ad3aa046070eee6ced202de993637d GIT binary patch literal 140 zcmX@j%ge<81QQ+{&*THrk3k%C@RRfQEE(ld}dx|NqoFsLFF$Fo80`A(wtPgB37VYMj$Q* OF+MUgGBOr116cruL?JZ* literal 0 HcmV?d00001 diff --git a/backend/app/core/__pycache__/config.cpython-312.pyc b/backend/app/core/__pycache__/config.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..45b44d99a99e493dd3e346a15d2f65671221fd22 GIT binary patch literal 3142 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 literal 0 HcmV?d00001 diff --git a/backend/app/core/__pycache__/logger.cpython-312.pyc b/backend/app/core/__pycache__/logger.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..62122344dd1a90f73503d0046bc2a8c07ac6715f GIT binary patch literal 1430 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 literal 0 HcmV?d00001 diff --git a/backend/app/core/config.py b/backend/app/core/config.py index e69de29..b5ff201 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -0,0 +1,67 @@ +""" +应用配置模块 +使用 Pydantic Settings 管理环境变量 +""" +from pydantic_settings import BaseSettings +from functools import lru_cache + + +class Settings(BaseSettings): + """应用配置类""" + + # 应用基础配置 + APP_NAME: str = "ACG Blog" + APP_VERSION: str = "0.1.0" + DEBUG: bool = True + + # 数据库配置 + DB_HOST: str = "localhost" + DB_PORT: int = 5432 + DB_USER: str = "postgres" + DB_PASSWORD: str = "postgres" + DB_NAME: str = "acg_blog" + + # Redis 配置 + REDIS_HOST: str = "localhost" + REDIS_PORT: int = 6379 + REDIS_DB: int = 0 + + # JWT 配置 + SECRET_KEY: str = "your-secret-key-change-in-production" + ALGORITHM: str = "HS256" + ACCESS_TOKEN_EXPIRE_MINUTES: int = 30 + REFRESH_TOKEN_EXPIRE_DAYS: int = 7 + + # CORS 配置 + BACKEND_CORS_ORIGINS: list[str] = [ + "http://localhost:5173", + "http://localhost:3000", + ] + + @property + def DATABASE_URL(self) -> str: + """生成数据库连接 URL""" + return f"postgres://{self.DB_USER}:{self.DB_PASSWORD}@{self.DB_HOST}:{self.DB_PORT}/{self.DB_NAME}" + + @property + def DATABASE_URL_ASYNC(self) -> str: + """生成异步数据库连接 URL""" + return f"asyncpg://{self.DB_USER}:{self.DB_PASSWORD}@{self.DB_HOST}:{self.DB_PORT}/{self.DB_NAME}" + + @property + def REDIS_URL(self) -> str: + """生成 Redis 连接 URL""" + return f"redis://{self.REDIS_HOST}:{self.REDIS_PORT}/{self.REDIS_DB}" + + class Config: + env_file = ".env" + case_sensitive = True + + +@lru_cache() +def get_settings() -> Settings: + """获取配置单例""" + return Settings() + + +settings = get_settings() diff --git a/backend/app/core/database.py b/backend/app/core/database.py new file mode 100644 index 0000000..aac05cc --- /dev/null +++ b/backend/app/core/database.py @@ -0,0 +1,39 @@ +""" +数据库模块 +Tortoise-ORM 初始化配置 +""" +from tortoise import Tortoise + +from app.core.config import settings +from app.core.logger import app_logger + + +async def init_db(): + """初始化数据库连接""" + app_logger.info("Initializing database connection...") + + await Tortoise.init( + db_url=settings.DATABASE_URL_ASYNC, + modules={ + "models": [ + "app.models.user", + "app.models.post", + "app.models.category", + "app.models.tag", + "app.models.comment", + ] + }, + use_tz=False, # 使用 UTC 时间 + timezone="Asia/Shanghai", # 使用上海时区 + ) + + # 生成 schema + await Tortoise.generate_schemas() + app_logger.info("Database connection established") + + +async def close_db(): + """关闭数据库连接""" + app_logger.info("Closing database connection...") + await Tortoise.close_connections() + app_logger.info("Database connection closed") diff --git a/backend/app/core/logger.py b/backend/app/core/logger.py new file mode 100644 index 0000000..91e024c --- /dev/null +++ b/backend/app/core/logger.py @@ -0,0 +1,42 @@ +""" +日志配置模块 +使用 Loguru 实现异步日志处理 +""" +import sys +from pathlib import Path +from loguru import logger + +# 日志目录 +LOG_DIR = Path(__file__).parent.parent.parent / "logs" +LOG_DIR.mkdir(exist_ok=True) + + +def setup_logger(): + """配置日志格式和输出""" + # 移除默认处理器 + logger.remove() + + # 控制台输出格式 + logger.add( + sys.stdout, + format="{time:YYYY-MM-DD HH:mm:ss} | {level: <8} | {name}:{function}:{line} - {message}", + level="INFO", + colorize=True, + ) + + # 文件输出格式 + logger.add( + LOG_DIR / "acg_blog_{time:YYYY-MM-DD}.log", + format="{time:YYYY-MM-DD HH:mm:ss} | {level: <8} | {name}:{function}:{line} - {message}", + level="DEBUG", + rotation="00:00", # 每天零点轮换 + retention="30 days", # 保留30天 + compression="zip", # 压缩旧日志 + encoding="utf-8", + ) + + return logger + + +# 初始化日志 +app_logger = setup_logger() diff --git a/backend/app/core/security.py b/backend/app/core/security.py new file mode 100644 index 0000000..1dc6596 --- /dev/null +++ b/backend/app/core/security.py @@ -0,0 +1,82 @@ +""" +安全模块 +包含 JWT 令牌生成、验证和密码哈希功能 +""" +from datetime import datetime, timedelta +from typing import Optional + +from jose import JWTError, jwt +from passlib.context import CryptContext +from fastapi import Depends, HTTPException, status +from fastapi.security import OAuth2PasswordBearer + +from app.core.config import settings + + +# 密码上下文(使用 bcrypt) +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + +# OAuth2 方案 +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login") + + +def verify_password(plain_password: str, hashed_password: str) -> bool: + """验证密码""" + return pwd_context.verify(plain_password, hashed_password) + + +def get_password_hash(password: str) -> str: + """哈希密码""" + return pwd_context.hash(password) + + +def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str: + """创建访问令牌""" + to_encode = data.copy() + if expires_delta: + expire = datetime.utcnow() + expires_delta + else: + expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) + + to_encode.update({"exp": expire, "type": "access"}) + encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM) + return encoded_jwt + + +def create_refresh_token(data: dict, expires_delta: Optional[timedelta] = None) -> str: + """创建刷新令牌""" + to_encode = data.copy() + if expires_delta: + expire = datetime.utcnow() + expires_delta + else: + expire = datetime.utcnow() + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS) + + to_encode.update({"exp": expire, "type": "refresh"}) + encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM) + return encoded_jwt + + +def decode_token(token: str) -> dict: + """解码令牌""" + try: + payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]) + return payload + except JWTError: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + + +async def get_current_user_id(token: str = Depends(oauth2_scheme)) -> str: + """从令牌中获取当前用户 ID""" + payload = decode_token(token) + user_id: str = payload.get("sub") + if user_id is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + return user_id diff --git a/backend/app/crud/__init__.py b/backend/app/crud/__init__.py new file mode 100644 index 0000000..6fea1c8 --- /dev/null +++ b/backend/app/crud/__init__.py @@ -0,0 +1 @@ +# CRUD module diff --git a/backend/app/crud/__pycache__/__init__.cpython-312.pyc b/backend/app/crud/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..79186f97406aaef67b03bbbc729d900650f14c26 GIT binary patch literal 140 zcmX@j%ge<81k)ZK&*THrk3k%C@RQ{X-Z6dd}dx|NqoFsLFF$Fo80`A(wtPgB37VYMj$Q* OF+MUgGBOr116cry(;-0s literal 0 HcmV?d00001 diff --git a/backend/app/crud/__pycache__/category.cpython-312.pyc b/backend/app/crud/__pycache__/category.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ba8cc07b0ee070f306f09a9d535329ed537697af GIT binary patch literal 4070 zcmd58w^+5rw9|vb|Hv3=1*N$iD{eEQi^C#=i3Dijyp2D zXBT=Xj_oG6b?P8gV`?kls#0-CD_T{J)2LG2Fa6LjARHaprc$aF5=;GXKq^Ok>U+Dl zm*Y$;t5T(oxHoU-y?Hb9-tWB`{^apE3AEdP{bO`QCggAUVK*VmY@7sUk|?B$C{*EM zbdc-f2Kg?Y;x``?2E{Is5{`VCD8gl;h;y7ZvrFQLD$QlhbUB8lpd(oZpU#!BS3myc zdo}IPpXsPsop}B4zq{ppfNDX`kiR;lM-%aI%n+W7YI=|wa(h@;dlPIplC@-Z=Dva3 z_y>?lqLMBOJLDAF#jAqCsiMNGlIo}gX^#N=5_0>HK`s`^r962N$a&S7oy#eXJedn* zUZn_TqXj3dAmR$3@92>g*JUxcihG+~xbrM|a`GLpKTn>-n_a)y+&ic2$di=}%fVv9 zZF>YAnDoO29(5@A*KkF$o(n}4Lt?6)Wbp_Eb-VGvT!P<550GaG1xM;Rd4D#Hwlel9 z@;05zYV?cw5{tKwe~BpEcftB~Zjygh6v+#ukz5si&Y!12K3NXBt26JezW$4vuXNOW z^22vlu3fhdaEBUPRO!6KGqi0;J<*u1GDGZ%GK2yHh!PE#ui1R!RC9M*EYbTlmgrX_ zdiU9IWI&B8-Ql63?g&dN-S*iJ4GkMby{aBMI}C0ZDbM%aq1?C&E$;{N3Hj^-`BWn1 zdnZHVp;^~`|IxI+dC}jzkOPWQn8(zP=c!4IVy19-;nknTl61W@;9cv zjSF()g12##m4g13#LZz2%gW-RHQ)iH~ki>^$u{&e~ zl!$d$2Obs%N6H>KD2$q}*@Y%QM??2`_r$l;-ns?3Zoyl(shJ3<*hha%tA(55KH6yr z@$jH(xD-{3u&7zZB6wK4A*3(CZ{v9&k8|)8NzoJs3754}m{g;DiZ>HW$|e?WX=Uu@ z%6q@HjvwUNZWuCy&h`M8^M@2+D%6IcP@rJQcG_rI{%fF_{$kbeHR9XiE6W6WNqZ8A z?W$e1)74Ye%O!sNtEl_bA?$KKBtn^UOwKq-amAQ7z9LB3T>NH^5OQGQfN2^Z+qBE85~PH`#T zzVbABwOq$qVm6ed1Ur0`Tnz{$#r0$M6*POL!Vpu?H$!{|>ZhC+=GB|OUcEkVg_+@k zok52Qb+!XOm=}m4Ms;;iGaP+kEv)OzaA>M-GJ+O9rv^o{oEp-=`7rC%SPf3$2h?Fh zJQt27RSofq0I(2XNXa1uAX5MWYcK$++BqOwK`{I5{mQ0v1ThovtbY!jdyw-I4(FL3vqZlpVFkv>J+ zIN@Hi1Nc916zCSL;0#uypZYs}~~F>JsKEfx9QrR*!H`bRh4cKqH`(Yxux3%jI*A zSpn=E+U{MV{v9Q~WR#`|#u!0H?#Vq`3J9EGsK~UrfZ!H!2+cMPgQtVI8h}ONhITX&}9J+Umr&C5L0_p zJ<=CqY79ySyglJn29Gm?*L1d>DTWDb@G&(Wb7y5h`BQ;wtsBJWwz=ZY+5F6FI1Jf;2EjN#+tC|<9n(sC)Rkf%6?PJd665p8g zXWyQ&_GNeJwbNHle^_35qi%Zt)c%Qq<%+6lc}ljs>e}g-r(Rxu^2j?yzbd*FnW_6( z@OJQ4{O(I@E~#qAKZ)e=W_VJzH&aZC%f~z!KPm5^n;ORc;(@jRy+hk-g|}Lt0yaoH zgQbQX3gJs46f&Hl&|pGI#*lZ1LN6u5v8==q3Mq*Q4r@@;qme;X?@K7`Fc@X%Co3E! z$l-IF;cd>Ukl;*B|2ks&eP-X6%s%j5&0 z`XjL)n31zw(+rPlO|$ZR#p1zcRBOtqHRaR- zH;&H8Q^!#p$cY1{c#U(26*rD#2y`( literal 0 HcmV?d00001 diff --git a/backend/app/crud/__pycache__/comment.cpython-312.pyc b/backend/app/crud/__pycache__/comment.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..76614596c1b388617f51f224a405f8e15a220c7f GIT binary patch literal 4659 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 Nweii#pW8K${{c|fCBy&# literal 0 HcmV?d00001 diff --git a/backend/app/crud/__pycache__/post.cpython-312.pyc b/backend/app/crud/__pycache__/post.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..33e33ef764193c325ee33c698ce81ea52366526a GIT binary patch literal 7810 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 jB=jK(en@tINa{Zox-CrY4Wh$Aj=21IS literal 0 HcmV?d00001 diff --git a/backend/app/crud/__pycache__/tag.cpython-312.pyc b/backend/app/crud/__pycache__/tag.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3afbb15885a7d68752dac3499fa7881f2256307a GIT binary patch literal 3864 zcmd5%Mn|{K`(D+} z5)qBg2*N_bKoA>a;^xH6#Kb>D6a3-_zl?zCe9;7>4ER%5E7K68i8txF z=iYPfzu)iNb6>yrcw7X^2fuum5FCX3ij6{HwlcRBlu@FP2vMjaBx#=z5&Fc4NU<#@ zS)UY0u77lYVe)OemQgKlh7kuLRa7Z{PN*r*51ddAtaQ}agpQPhTNS<>MA#+ z?gYn(;7;h(aQV=D!^4}pI+CfLCwZz@jq6>zWAVLeztR;O80d=gw9*B~ZWPTnM)1d~H+CwkcoR z{G1zH4>Y#Yk7zTSkXot1v}C&He*o!n*q99|?x+k@?gw)x_kiRWC>Df#38+S;4F(Py z@|Pimo#-{U2u3>=$B0+qtgJuQr~a>SxqRf%l@BKu1{+7%Y+UXDbunD*&*mU!HQ-4!=CFnQ^{+6@7=+v6Ip&8ZS)+_&k_N<4I6% z=X4p8p^!H0Im%PRJ|QER=|Q&B11^4f_~hlYUs)%F1L9__ATFF%j&n7ptB@&*HbaCf zuOarKP{@RUuZG5uw{U!GNFDTPcY`RLKDc~z+sL-;mNETeU_;KkVOX5@RSmoHBJtg8 zL$RWPzDw)bX-5NH5Y#eE^A`N(+>n%R4~l=5KM^GUc!u^Co0p2#+D^rFXiv{=MXhH zReuPq%S)tgeO@O1>e0rL#-nRHdNqh(pwzm-&7l8DrsdoMecEOaw?$@EaCGg+ z+N0}o?%FAL?X-W{v@ejaKyog*Yv;99FwcR;b{|mFUdbkEKs(21yN8{V9H@IJ$i?&D z5g2OI0h(2M?Q2wMW1T5}G68rC^1FGh&HSeq2Q2nkg&DMPdCTW8FJ1$HZ{<(H{1mj` ziQ!|ViL^5;a?BqbKfuh~$nOGeLCQ2d_-dqh)Jm2^!9wy}(7h3db$}=`ure1|b3U+U zj9mz<&w1Bd_sCy8yzQsp%F$;>o`v*Ue<9eM^EFR7oAVChYq|;dW=YGuhWYqFO}k7_ z(sqHJS_RtIw4JeUB+!rwnRbS&3}-ZouWmGIxT4X%l#))O?v6&EOUIJ7#Sx7vsW^6P zkU|piK2_h7QVdbkIY(Zs;9H8C3Bd}~@Hyil6su9-RW;b|R4U0a_IN9bS`@fZD=1A6 zQJCMOO_2LvBR{gbOVZN3AV~LQ#XNy(%sJ^F_spU?Cla~JwB?-z?O*h&JH8<+p4f;> z)fJbjvzHPj|M7eB1gdP@tj6NA*s>i#gCvE!bAsdcJb`NL#V^G1eY2?AUZCXzrk5-Q z(F>~Vj#;d1576>UObq2jRFm6hu`(U63!qt0B7mJ?C%+eJ_?I#`km&ER zXhKR1FMP>20nutwYl4T};tt0cPG~}Wrj;^Y=A92%f$<=J0Q~TOg86Zu7b&IJ1%a{~ dGNG$~Cbc)@`)SK{f&%{UxczT!e8p*Q>JQ>vR?z?e literal 0 HcmV?d00001 diff --git a/backend/app/crud/__pycache__/user.cpython-312.pyc b/backend/app/crud/__pycache__/user.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ad533ca73e02a6040065473f54548091dc64ea8c GIT binary patch literal 4356 zcmd5KyX*CuS+6~Q@UTsAgSW*_;#UfG2@tCZsp?iiNR{Juwd{=Xnzh%x zGYhf1NCar)3X_7;f)P=$6t!LBlo+XMC@2!{k2X^O*fP;NBdWG4w&CAmQ-S=e=gebS zFQk>KM#@Ng?wNbOxsUHW?%lr>6?q9fWB>Sb^jigl{1acCN3fNZ*FiZ?6w*Z$s&Fyd z&vkJWbw0-T3tfV#3o)_Z)#d7UceyFy$SXths_q_KI7NmyfdxLs3J`j$9F6`G-7UYcLgQ^}H3~Snv zI8#EsVXfDY4y!EMGnDO(*t3`o?8;-vtZ;rjt9v{ajG;!`8kK)Tg_yCc=!lE2|kv)U; zwX+MCvMvX+E<1(<`;&4k{vm&`*zh2)NPa>F3Z8Vv($yP=I{}zbR54sk)e~$W0#s%n z9<&qiTj>Gu9YTR*J!|i?-JeB+k|ZC}NgL58=2|S>ali?pa6f_3&vEDZw?&ccBQKIs z;kWz|8srmYfL$E_!{X^HHG4a1?!NQ!!q3lHi=Utd7gahZc!mLNxO$>7U1f&Y6J?qn zqznT>!|7{Qx4ycgdrvHW@cS&@r$+Sd1L4RabwKG34-R%mSVHN>eK!ma83h=02Zn%x z;ef+eL$=R{BR&h_E_rl}JaCb+_2=I>`^NYyx8!YUxp7u*oRgc=QuBRXHKtWmtod+vUMOCdcoq76@zL>HYd)0 zhAZ;fax4gc3TLUhTXIuc-aadDpOf3tQrnEDZANNaWhFSgsP>1Wv9AD06|fqMqpbZ3eX5uz-3keAyJ$4LpE3!N6tOz6Ejdd z89KuZTqpw@^Bhx=7rI#*hd1Xt=?SJWXS97F))s(y`Pm0NSLR*vN^xXq&Dz`xO1{^Voa2L|yq^84&pGy;F`!M7Bu(;AwQW>> zWj-n-1+zL^e&G{X;TA?NF5I|o`6bAkRfAz-7)8!f&C|ORY`9FjT1S`~*43c+7|*KF ziYyocGOr=J1|Kt{*fa1tEMd%*P6g420K_`Fb>ud2NiyAXSS;H>uye`f!DNyThQ?T6)4rBm8x!M59_ z8^$`uw~Qw~E3MCnq`E%iA|>VLgJ*;9ek1MMG~?SeFO@+26cK;RBM7FVs#UEXWA_TW zk{Vn^bFWVrNE#iYdiKf%NzL_PAm=@ggJlR1rh98l*K&yfK<`~1z5CHymTQ+T+*tVJ zmz_a@dBDP|P~eDWzCvbWm9-KW-KXJ#oy0YK;e_6+4(QPcy6y2%nA?phtnC7kbzW6k zZk?4|XI^}DPTrfA_JZ4_lKGPI+fvooo2k-tux&Qjc1vo9F3TB(#Qz$#v$=97y-3@I z_crfbo6Wec6YyJk8^o8I%`{1w53>0wwC^JdZ<1YVpKe(57C$()ciInAIo#)d(*dS6 zHS~)Ur?zfHPfInkQq7E~DW!tQ@Q?$m8r~A&@S+g?FA0+}D4qD9BNW2hZYX4UL!th- zl8B-13x$512*+%TI}}pl5$x8WC`2Rus@@w{3|`Y2Lw8x*#du`pDDVun;txt8VcSqN zfG~su@pz2kJ}1ZuNMaouX1t;>{}1efDt?lDE>!eaz2xo;)L%Cr%KtXeD zpV{2}!KcdfkCw5=9ul-nz#Nhno5x?y5NJ{z%h;HR`y6O&JCci(b_Tty8Ct`o4-G~K z4w?=#B=}p^0Nz$(S_8(Ub>bIeZzRst22G75SX3Xft^;Nj!NH6I1c&hd82cv9U`~Vo zA67l>R`Bd(JK+^vNVFh`3{NS&&v8`vmy6JiefijJ^JD-3 literal 0 HcmV?d00001 diff --git a/backend/app/crud/category.py b/backend/app/crud/category.py new file mode 100644 index 0000000..f7d50c6 --- /dev/null +++ b/backend/app/crud/category.py @@ -0,0 +1,72 @@ +""" +分类 CRUD 操作 +""" +from typing import Optional, List +from app.models.category import Category + + +class CategoryCRUD: + """分类 CRUD 操作类""" + + @staticmethod + async def get_by_id(category_id: str) -> Optional[Category]: + """根据 ID 获取分类""" + return await Category.filter(id=category_id).first() + + @staticmethod + async def get_by_slug(slug: str) -> Optional[Category]: + """根据 slug 获取分类""" + return await Category.filter(slug=slug).first() + + @staticmethod + async def get_all() -> List[Category]: + """获取所有分类""" + return await Category.all() + + @staticmethod + async def create(name: str, slug: str, description: Optional[str] = None) -> Category: + """创建分类""" + category = await Category.create( + name=name, + slug=slug, + description=description + ) + return category + + @staticmethod + async def update(category_id: str, **kwargs) -> Optional[Category]: + """更新分类""" + category = await Category.filter(id=category_id).first() + if category: + for key, value in kwargs.items(): + if value is not None and hasattr(category, key): + setattr(category, key, value) + await category.save() + return category + + @staticmethod + async def delete(category_id: str) -> bool: + """删除分类""" + category = await Category.filter(id=category_id).first() + if category: + await category.delete() + return True + return False + + @staticmethod + async def get_with_post_count() -> List[dict]: + """获取分类及其文章数量""" + categories = await Category.all().prefetch_related("posts") + result = [] + for cat in categories: + result.append({ + "id": str(cat.id), + "name": cat.name, + "slug": cat.slug, + "description": cat.description, + "post_count": len(cat.posts) if cat.posts else 0 + }) + return result + + +category_crud = CategoryCRUD() diff --git a/backend/app/crud/comment.py b/backend/app/crud/comment.py new file mode 100644 index 0000000..1a1d56a --- /dev/null +++ b/backend/app/crud/comment.py @@ -0,0 +1,91 @@ +""" +评论 CRUD 操作 +""" +from typing import Optional, List, Tuple +from app.models.comment import Comment +from app.models.post import Post + + +class CommentCRUD: + """评论 CRUD 操作类""" + + @staticmethod + async def get_by_id(comment_id: str) -> Optional[Comment]: + """根据 ID 获取评论""" + return await Comment.filter(id=comment_id).first() + + @staticmethod + async def get_by_post( + post_id: str, + page: int = 1, + page_size: int = 20, + approved_only: bool = True + ) -> Tuple[List[Comment], int]: + """获取文章的所有评论(树形结构)""" + query = Comment.filter(post_id=post_id) + + if approved_only: + query = query.filter(is_approved=True) + + total = await query.count() + comments = await query \ + .prefetch_related("author", "replies__author") \ + .filter(parent_id=None) \ + .offset((page - 1) * page_size) \ + .limit(page_size) \ + .order_by("created_at") + + return comments, total + + @staticmethod + async def create( + content: str, + author_id: str, + post_id: str, + parent_id: Optional[str] = None, + is_approved: bool = True + ) -> Comment: + """创建评论""" + comment = await Comment.create( + content=content, + author_id=author_id, + post_id=post_id, + parent_id=parent_id, + is_approved=is_approved + ) + + # 更新文章的评论数 + await Post.filter(id=post_id).update(comment_count=Post.comment_count + 1) + + return comment + + @staticmethod + async def update(comment_id: str, **kwargs) -> Optional[Comment]: + """更新评论""" + comment = await Comment.filter(id=comment_id).first() + if comment: + for key, value in kwargs.items(): + if value is not None and hasattr(comment, key): + setattr(comment, key, value) + await comment.save() + return comment + + @staticmethod + async def delete(comment_id: str) -> bool: + """删除评论""" + comment = await Comment.filter(id=comment_id).first() + if comment: + post_id = comment.post_id + await comment.delete() + # 更新文章的评论数 + await Post.filter(id=post_id).update(comment_count=Post.comment_count - 1) + return True + return False + + @staticmethod + async def approve(comment_id: str) -> Optional[Comment]: + """审核通过评论""" + return await CommentCRUD.update(comment_id, is_approved=True) + + +comment_crud = CommentCRUD() diff --git a/backend/app/crud/post.py b/backend/app/crud/post.py new file mode 100644 index 0000000..fbde7e1 --- /dev/null +++ b/backend/app/crud/post.py @@ -0,0 +1,163 @@ +""" +文章 CRUD 操作 +""" +from datetime import datetime +from typing import Optional, List, Tuple +from app.models.post import Post, PostTag +from app.models.user import User +from app.models.category import Category +from app.models.tag import Tag + + +class PostCRUD: + """文章 CRUD 操作类""" + + @staticmethod + async def get_by_id(post_id: str) -> Optional[Post]: + """根据 ID 获取文章""" + return await Post.filter(id=post_id).first() + + @staticmethod + async def get_by_slug(slug: str) -> Optional[Post]: + """根据 slug 获取文章""" + return await Post.filter(slug=slug).first() + + @staticmethod + async def get_all( + page: int = 1, + page_size: int = 10, + status: str = "published", + category_id: Optional[str] = None, + tag_id: Optional[str] = None + ) -> Tuple[List[Post], int]: + """获取文章列表(分页)""" + query = Post.all() + + if status: + query = query.filter(status=status) + + if category_id: + query = query.filter(category_id=category_id) + + if tag_id: + query = query.filter(tags__id=tag_id) + + total = await query.count() + posts = await query \ + .prefetch_related("author", "category", "tags") \ + .offset((page - 1) * page_size) \ + .limit(page_size) \ + .order_by("-created_at") + + return posts, total + + @staticmethod + async def get_by_author( + author_id: str, + page: int = 1, + page_size: int = 10 + ) -> Tuple[List[Post], int]: + """获取指定作者的文章列表""" + query = Post.filter(author_id=author_id) + total = await query.count() + posts = await query \ + .prefetch_related("author", "category", "tags") \ + .offset((page - 1) * page_size) \ + .limit(page_size) \ + .order_by("-created_at") + return posts, total + + @staticmethod + async def create( + title: str, + slug: str, + content: str, + author_id: str, + summary: Optional[str] = None, + cover_image: Optional[str] = None, + category_id: Optional[str] = None, + tag_ids: Optional[List[str]] = None, + status: str = "draft", + meta_title: Optional[str] = None, + meta_description: Optional[str] = None + ) -> Post: + """创建文章""" + post = await Post.create( + title=title, + slug=slug, + content=content, + author_id=author_id, + summary=summary, + cover_image=cover_image, + category_id=category_id, + status=status, + meta_title=meta_title, + meta_description=meta_description + ) + + # 添加标签 + if tag_ids: + for tag_id in tag_ids: + await PostTag.create(post_id=post.id, tag_id=tag_id) + + return post + + @staticmethod + async def update(post_id: str, **kwargs) -> Optional[Post]: + """更新文章""" + post = await Post.filter(id=post_id).first() + if not post: + return None + + # 处理标签更新 + if "tag_ids" in kwargs: + tag_ids = kwargs.pop("tag_ids") + # 删除旧标签关联 + await PostTag.filter(post_id=post_id).delete() + # 添加新标签关联 + for tag_id in tag_ids: + await PostTag.create(post_id=post_id, tag_id=tag_id) + + # 处理发布状态更新 + if kwargs.get("status") == "published" and not post.published_at: + kwargs["published_at"] = datetime.utcnow() + + for key, value in kwargs.items(): + if value is not None and hasattr(post, key): + setattr(post, key, value) + + await post.save() + return post + + @staticmethod + async def delete(post_id: str) -> bool: + """删除文章""" + post = await Post.filter(id=post_id).first() + if post: + await post.delete() + return True + return False + + @staticmethod + async def increment_view_count(post_id: str) -> None: + """增加浏览量""" + await Post.filter(id=post_id).update(view_count=Post.view_count + 1) + + @staticmethod + async def get_hot_posts(limit: int = 10) -> List[Post]: + """获取热门文章(按浏览量排序)""" + return await Post.filter(status="published") \ + .prefetch_related("author", "category") \ + .order_by("-view_count") \ + .limit(limit) + + @staticmethod + async def get_recent_posts(limit: int = 10) -> List[Post]: + """获取最新文章""" + return await Post.filter(status="published") \ + .prefetch_related("author", "category", "tags") \ + .order_by("-created_at") \ + .limit(limit) + + +post_crud = PostCRUD() diff --git a/backend/app/crud/tag.py b/backend/app/crud/tag.py new file mode 100644 index 0000000..ae49786 --- /dev/null +++ b/backend/app/crud/tag.py @@ -0,0 +1,66 @@ +""" +标签 CRUD 操作 +""" +from typing import Optional, List +from app.models.tag import Tag + + +class TagCRUD: + """标签 CRUD 操作类""" + + @staticmethod + async def get_by_id(tag_id: str) -> Optional[Tag]: + """根据 ID 获取标签""" + return await Tag.filter(id=tag_id).first() + + @staticmethod + async def get_by_slug(slug: str) -> Optional[Tag]: + """根据 slug 获取标签""" + return await Tag.filter(slug=slug).first() + + @staticmethod + async def get_by_name(name: str) -> Optional[Tag]: + """根据名称获取标签""" + return await Tag.filter(name=name).first() + + @staticmethod + async def get_all() -> List[Tag]: + """获取所有标签""" + return await Tag.all() + + @staticmethod + async def create(name: str, slug: str) -> Tag: + """创建标签""" + tag = await Tag.create(name=name, slug=slug) + return tag + + @staticmethod + async def update(tag_id: str, **kwargs) -> Optional[Tag]: + """更新标签""" + tag = await Tag.filter(id=tag_id).first() + if tag: + for key, value in kwargs.items(): + if value is not None and hasattr(tag, key): + setattr(tag, key, value) + await tag.save() + return tag + + @staticmethod + async def delete(tag_id: str) -> bool: + """删除标签""" + tag = await Tag.filter(id=tag_id).first() + if tag: + await tag.delete() + return True + return False + + @staticmethod + async def get_or_create(name: str, slug: str) -> Tag: + """获取或创建标签""" + tag = await TagCRUD.get_by_slug(slug) + if tag: + return tag + return await TagCRUD.create(name, slug) + + +tag_crud = TagCRUD() diff --git a/backend/app/crud/user.py b/backend/app/crud/user.py new file mode 100644 index 0000000..bcfeb55 --- /dev/null +++ b/backend/app/crud/user.py @@ -0,0 +1,75 @@ +""" +用户 CRUD 操作 +""" +from typing import Optional +from app.models.user import User +from app.core.security import get_password_hash, verify_password + + +class UserCRUD: + """用户 CRUD 操作类""" + + @staticmethod + async def get_by_id(user_id: str) -> Optional[User]: + """根据 ID 获取用户""" + return await User.filter(id=user_id).first() + + @staticmethod + async def get_by_username(username: str) -> Optional[User]: + """根据用户名获取用户""" + return await User.filter(username=username).first() + + @staticmethod + async def get_by_email(email: str) -> Optional[User]: + """根据邮箱获取用户""" + return await User.filter(email=email).first() + + @staticmethod + async def get_by_username_or_email(username_or_email: str) -> Optional[User]: + """根据用户名或邮箱获取用户""" + return await User.filter( + username=username_or_email + ).first() or await User.filter( + email=username_or_email + ).first() + + @staticmethod + async def create(username: str, email: str, password: str) -> User: + """创建用户""" + password_hash = get_password_hash(password) + user = await User.create( + username=username, + email=email, + password_hash=password_hash + ) + return user + + @staticmethod + async def update(user_id: str, **kwargs) -> Optional[User]: + """更新用户""" + user = await User.filter(id=user_id).first() + if user: + for key, value in kwargs.items(): + if value is not None and hasattr(user, key): + setattr(user, key, value) + await user.save() + return user + + @staticmethod + async def authenticate(username_or_email: str, password: str) -> Optional[User]: + """验证用户登录""" + user = await UserCRUD.get_by_username_or_email(username_or_email) + if not user: + return None + if not verify_password(password, user.password_hash): + return None + return user + + @staticmethod + async def is_superuser(user_id: str) -> bool: + """检查用户是否为超级用户""" + user = await User.filter(id=user_id).first() + return user.is_superuser if user else False + + +user_crud = UserCRUD() diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..e2313c5 --- /dev/null +++ b/backend/app/models/__init__.py @@ -0,0 +1 @@ +# Models module diff --git a/backend/app/models/__pycache__/__init__.cpython-312.pyc b/backend/app/models/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a5a60ca97371bd98645d04f1a3ca6eacf0ecaa92 GIT binary patch literal 142 zcmX@j%ge<81Unxb&lCXCk3k%C@RFbN@`AVOniK1US>&ryk0@&FAf`^U};XOT@fo#HzN=i PgBTx~85tRin1L(+@^2zN literal 0 HcmV?d00001 diff --git a/backend/app/models/__pycache__/category.cpython-312.pyc b/backend/app/models/__pycache__/category.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..958293348de9a51651fd89be09ab661a256a1a3d GIT binary patch literal 1437 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 literal 0 HcmV?d00001 diff --git a/backend/app/models/__pycache__/post.cpython-312.pyc b/backend/app/models/__pycache__/post.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f4364919d590b6b2fa1e4c384fa1aa4a9a1da9bd GIT binary patch literal 3860 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 aAe?)K-hXZ6ajE^`{nPWO|3jcA?cV?n9631v literal 0 HcmV?d00001 diff --git a/backend/app/models/__pycache__/user.cpython-312.pyc b/backend/app/models/__pycache__/user.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d99bef4490185174a5673ab8d8d3579e4cceb894 GIT binary patch literal 1991 zcmaJ?UuYCZ7@ytSz1(f~yqtF?CgvJ#{=hyo4`O`~X=zMT6=Q9iSh$8R>&=*K+`Bzz zchyLRZJU-8TRdoK1ff_c?O6{^Ay|op`qn~UJUF=PK&epg(!5n-DfrUP>?UUvb>P1F zX1?Ei|MvU7`!y1g09`-c`C0E50Qi#~)|R-+tQ@Ay9DpDNAP0GaOL24$7<`6L@f^Do zj6g<62^ldZa)1Y~0VvJ_7)0Wm5tleF}Cv626wQT(d@+9ww^%>Pl88vWI&4qGbTiamG*V`wCQMtV~!#~9Rw*Ja;X3hkTC6qxc&hE zkVnC3X8N&J3S8m+t5nE)yYVpqJwgHUP%H(Tfk;n9woqhf9kTTq$Z$z&+LoV+Yo!|H zQlx1+;vu`A;3W??l%h?IQ4hEEiMX~>tci;~6}J(_*WosLxPd3?OO!S>^(CHxYcF*) zahtrd*-Xo(vw7DJyJkVEV`hueO@wg^VTYB|`dS&ImBHtZkrs%KYK~zqZ>In!+(gw2 zpDF<&WwcYOfwGhK6bU+6{kVfjD@0Zr>p5FDv&;XnS3ydaKe)Pd^?T1u{o2P%m)@^` zmapIVMB#BOmEs5``h8kto{9R`3-u4rkH2|933y^6WF5osK%R!XH@>^OxVUuHy?gT< z)ev3N;0YTSuGI_QG=6d$3pc5dx$M0haahIq(!Z{Mw6yrGAFuCI{$l;+Vq@<5^4tQA zT-F@hRI}zuRfF)M-Nwa*#uxKVRA>$5As%TQ8i}`7+UfRa<3N6rO9?XpMR*WqsUI9g zwnjvUvexO=7t$Ef-h`@VA5=sVQq`=ML8?k5RizE;7>t)y^|+%Ms}j-Hju?o9Obij$ zvy+#>3bXw{*}H3E$S@}lVRIU#?TI5=`WVW>2`!hK@OyoN86C{c5P|uC*jUo^Qph#Yl^)S~gbI)3TS7)f2O7vC?!wEgC<6=Ioi

EHYgUV*Si3%`FFlJse z70^tpDyHKWkns5UzTpuz+KAvdI;0t zvuHtUCp4NB5*|aRY+qC0h;9;j$TSV4W&NwLZmC+@)=wZJQ`T~Fw3}$4Br?1@V||?T z@WT?x$*}>MRJ^2&WXiquXQ`|lee`&2m$K1U&xjR2nmAlJwcQG(Od~2#AzwW+TOY{{+ zs)@dGqHjKM>$ytekULt7B=d)>kz_fNEXL<|+4KVOgL@Ih#|-!m`a7b%jJo7GRr-vN%}?(sY)tONlk{|=IWfZqGTIG4=t PJiq(w?tcJfJvaUbnCbf+ literal 0 HcmV?d00001 diff --git a/backend/app/models/category.py b/backend/app/models/category.py new file mode 100644 index 0000000..6fa6bc5 --- /dev/null +++ b/backend/app/models/category.py @@ -0,0 +1,22 @@ +""" +分类模型 +""" +import uuid +from tortoise import fields, models + + +class Category(models.Model): + """分类模型""" + + id = fields.UUIDField(pk=True, default=uuid.uuid4) + name = fields.CharField(max_length=50, unique=True, description="分类名称") + slug = fields.CharField(max_length=50, unique=True, description="URL别名") + description = fields.TextField(null=True, description="分类描述") + created_at = fields.DatetimeField(auto_now_add=True, description="创建时间") + + class Meta: + table = "categories" + ordering = ["name"] + + def __str__(self): + return self.name diff --git a/backend/app/models/comment.py b/backend/app/models/comment.py new file mode 100644 index 0000000..908c959 --- /dev/null +++ b/backend/app/models/comment.py @@ -0,0 +1,49 @@ +""" +评论模型 +""" +import uuid +from tortoise import fields, models + + +class Comment(models.Model): + """评论模型""" + + id = fields.UUIDField(pk=True, default=uuid.uuid4) + content = fields.TextField(description="评论内容") + is_approved = fields.BooleanField(default=True, description="是否审核通过") + + # 关联用户(评论者) + author = fields.ForeignKeyField( + "models.User", + related_name="comments", + on_delete=fields.CASCADE, + description="评论者" + ) + + # 关联文章 + post = fields.ForeignKeyField( + "models.Post", + related_name="comments", + on_delete=fields.CASCADE, + description="所属文章" + ) + + # 自关联(回复) + parent = fields.ForeignKeyField( + "models.Comment", + related_name="replies", + on_delete=fields.CASCADE, + null=True, + description="父评论" + ) + + # 时间戳 + created_at = fields.DatetimeField(auto_now_add=True, description="创建时间") + updated_at = fields.DatetimeField(auto_now=True, description="更新时间") + + class Meta: + table = "comments" + ordering = ["created_at"] + + def __str__(self): + return f"Comment by {self.author.username} on {self.post.title}" diff --git a/backend/app/models/post.py b/backend/app/models/post.py new file mode 100644 index 0000000..5860500 --- /dev/null +++ b/backend/app/models/post.py @@ -0,0 +1,94 @@ +""" +文章模型 +""" +import uuid +from datetime import datetime +from tortoise import fields, models + + +class Post(models.Model): + """文章模型""" + + id = fields.UUIDField(pk=True, default=uuid.uuid4) + title = fields.CharField(max_length=200, description="文章标题") + slug = fields.CharField(max_length=200, unique=True, description="URL别名") + content = fields.TextField(description="文章内容(Markdown)") + summary = fields.TextField(null=True, description="文章摘要") + cover_image = fields.CharField(max_length=500, null=True, description="封面图片URL") + + # 关联用户(作者) + author = fields.ForeignKeyField( + "models.User", + related_name="posts", + on_delete=fields.CASCADE, + description="作者" + ) + + # 关联分类 + category = fields.ForeignKeyField( + "models.Category", + related_name="posts", + on_delete=fields.SET_NULL, + null=True, + description="分类" + ) + + # 标签(多对多) + tags = fields.ManyToManyField( + "models.Tag", + related_name="posts", + through="post_tags", + description="标签" + ) + + # 统计数据 + view_count = fields.IntField(default=0, description="浏览量") + like_count = fields.IntField(default=0, description="点赞数") + comment_count = fields.IntField(default=0, description="评论数") + + # 状态:draft(草稿), published(已发布), archived(已归档) + status = fields.CharField( + max_length=20, + default="draft", + description="文章状态" + ) + + # SEO + meta_title = fields.CharField(max_length=200, null=True, description="SEO标题") + meta_description = fields.TextField(null=True, description="SEO描述") + + # 时间戳 + published_at = fields.DatetimeField(null=True, description="发布时间") + created_at = fields.DatetimeField(auto_now_add=True, description="创建时间") + updated_at = fields.DatetimeField(auto_now=True, description="更新时间") + + class Meta: + table = "posts" + ordering = ["-created_at"] + indexes = [ + ("status", "published_at"), + ("author", "status"), + ] + + def __str__(self): + return self.title + + +class PostTag(models.Model): + """文章-标签关联表""" + + id = fields.UUIDField(pk=True, default=uuid.uuid4) + post = fields.ForeignKeyField( + "models.Post", + on_delete=fields.CASCADE, + description="文章" + ) + tag = fields.ForeignKeyField( + "models.Tag", + on_delete=fields.CASCADE, + description="标签" + ) + + class Meta: + table = "post_tags" + unique_together = (("post", "tag"),) diff --git a/backend/app/models/tag.py b/backend/app/models/tag.py new file mode 100644 index 0000000..40ef96d --- /dev/null +++ b/backend/app/models/tag.py @@ -0,0 +1,21 @@ +""" +标签模型 +""" +import uuid +from tortoise import fields, models + + +class Tag(models.Model): + """标签模型""" + + id = fields.UUIDField(pk=True, default=uuid.uuid4) + name = fields.CharField(max_length=50, unique=True, description="标签名称") + slug = fields.CharField(max_length=50, unique=True, description="URL别名") + created_at = fields.DatetimeField(auto_now_add=True, description="创建时间") + + class Meta: + table = "tags" + ordering = ["name"] + + def __str__(self): + return self.name diff --git a/backend/app/models/user.py b/backend/app/models/user.py new file mode 100644 index 0000000..4f0ef59 --- /dev/null +++ b/backend/app/models/user.py @@ -0,0 +1,28 @@ +""" +用户模型 +""" +import uuid +from datetime import datetime +from tortoise import fields, models + + +class User(models.Model): + """用户模型""" + + id = fields.UUIDField(pk=True, default=uuid.uuid4) + username = fields.CharField(max_length=50, unique=True, description="用户名") + email = fields.CharField(max_length=255, unique=True, description="邮箱") + password_hash = fields.CharField(max_length=255, description="密码哈希") + avatar = fields.CharField(max_length=500, null=True, description="头像URL") + bio = fields.TextField(null=True, description="个人简介") + is_active = fields.BooleanField(default=True, description="是否激活") + is_superuser = fields.BooleanField(default=False, description="是否超级用户") + created_at = fields.DatetimeField(auto_now_add=True, description="创建时间") + updated_at = fields.DatetimeField(auto_now=True, description="更新时间") + + class Meta: + table = "users" + ordering = ["-created_at"] + + def __str__(self): + return self.username diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py new file mode 100644 index 0000000..fff1fac --- /dev/null +++ b/backend/app/schemas/__init__.py @@ -0,0 +1 @@ +# Schemas module diff --git a/backend/app/schemas/__pycache__/__init__.cpython-312.pyc b/backend/app/schemas/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..27a4d27df8d576a8b886b0bd965fe27acd612db3 GIT binary patch literal 143 zcmX@j%ge<81iu~}&lCjGk3k%C@ROSMrv+iaZG%CW?p7Ve7s&kfPOytG$z% zS(-Do1}>n>Ui7SK(~AW{vz~e`XIj}whk2p%7R{0jh8zwhQ1&K3SrDj@7GW#Whp?Hn zED2O05|NjcCCQIVhiI5aNazeq18=y+t8#Ayy(LwTdhrW|87rTnS6pg4?olxBe78~m z@!Q>%we8hw!9NeU&!gj3WqW?DUcR|Kf4gz5x>H@fCwehL?F_Suj#bEes%ul0H*@qL z4Dt1q>pOR@dTP$B=j~m7n*USc3|S_{M&iIYUj_ZkP;!9jNFN4(Ay zow00(vR0Pc4=VNRH`-Y?@QgR!1mQ+OG^xUo)N)qd$kP0bGYh);S)enF;~-qzsn4FN zpn4%V(N?y>%><)jC=kqq9%BidbfCaKU`J3ip|a<2iU*6mgaQl0zhrm{{Yt-p+KYz* z)7c{+9^E`uy7V?fZU=mtgcVqxP)t_rjN`$tZI%&X3qx z-Kc;6wJv!PLNA+c*5SxKjps9*Qjt#R+Ou)?L82C%{WyIU#SmHiBQ10JUhPHZMUbOFb)Ov>JE|%ZgRE7cTozeAyTG!azcv-F~V?IGl^>l(K z!Smk;mcn|(JDTzf6~OQ>#U9AjTF&-%Vy}RkQ((C730-}5op@m$ANwb&gBKA)aSR^2 z3j|iDzr7eWW!T?42EvWXn+opO5;60Rm0>cN{Z`_Q#;-cwK)vYTOSCWhNIm1j62tL>h8|=&a89` zLLjkC3WY$63KptwO2qn5ina8)g}zj>59@uA(njN3+pH9P>N$5No6Qnw2lm`E=iIqJ z-}l{f_m@=45a2oT+fVM#vLO79FUyY-IZJoIxgFPE+lLXt>CQukSv(HW4j#_^}9yYH}s}$%Tbm z-^1#Rl3TWlq+AHju!KYWJazd7n3ng-0SudkDRj#!+a+RI%&@GI=TwX6CoSv4s$GmS znq@g&-m<6$lh70x7V`tjGL~hR%U)n3EWTyk5SAb(Jbn4_>*M>1Uf~_;eL(WT_$fPo zmXw`wyHXkVIllgQKHS-GrAFg8DjNLxePAvK_vJ13d;2c;Uhlq>uJ?{ij7*=X%OiCC za@SQrgD&~AGzQJHfW9fuNny8K2(yAPa~f1fYs=~+PSlN%11fY*g=)rGDxzXPC7_6Q zGN!LA{IIN>;i?WFKD_wpqq(c22TYM7EljI;eqgyywwi#~N7LVhG>#r%@rq4>2*{ZR zO+%8R7HJonZZsTmTnz0&gBqZl(BPiwIy4-U4d@|s+~7$5OJLf7e6ilU6JeZ}citb^ zc6IFb`TD@YiP7ngF2`oh*X4uZI5@=G*zSVN|HYQ0r#l?snq;ai^iEdrJhh?s@XEIj zFW!!%oTXT605yb(>&%Ql8x_=H2*V0A9e`}cE@&|BBW@ueTb{f6+U>pdExQ1a_h$1m z@7LvB;r_An+WXH!rgQ&9CW<4WMz|M4&yjO*h>6TGP(loN?NOojXe|xuH5pFdva0r0 zgEJm&!{v`3zgnEQ5aGfk*I|0qCln84mZ|nRJFw}hbz%{~X9=~lvQ8{Dz(DNjx=6p_ zOvC%V@@cm)mc1c&0L(I{saLY>AfWE4YC!xBkuf#=ln%i>12`c{bV_J!>YvGk7`_03 zXArl-)K=&evWML}CZYWpLxU*#+re<+8hT=Kq>=8PJl5DeIB{tD@a&11WApOhKeDI{ z{4Ib9SAbuxLZ?+BK7-5(I$qe30jZi|rdTH86MyemjgFXRd@|Aw>WzI z=Gu%NR{~!kU&rc#{#4%+{zZ9{z5$(4z`hOU&zLBRe@T)kKT!p-|ADaef$)4&mBhp1 ztl1Q>Z5lE0kU0BxQ^2;Fl*HY$qnO&=G&FGleQ28NlHi|h3eYtB6|wiS`9y$*Z}DI9 CNQXuM literal 0 HcmV?d00001 diff --git a/backend/app/schemas/__pycache__/post.cpython-312.pyc b/backend/app/schemas/__pycache__/post.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..de5d2aa30f29ee2a9f841468081c9518a732c7fb GIT binary patch literal 5755 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 literal 0 HcmV?d00001 diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py new file mode 100644 index 0000000..04d58bd --- /dev/null +++ b/backend/app/schemas/auth.py @@ -0,0 +1,29 @@ +""" +认证 Schema +""" +from pydantic import BaseModel, EmailStr, Field + + +class LoginRequest(BaseModel): + """登录请求 Schema""" + username: str = Field(..., description="用户名或邮箱") + password: str = Field(..., description="密码") + + +class RegisterRequest(BaseModel): + """注册请求 Schema""" + username: str = Field(..., min_length=3, max_length=50, description="用户名") + email: EmailStr = Field(..., description="邮箱") + password: str = Field(..., min_length=6, max_length=100, description="密码") + + +class TokenResponse(BaseModel): + """令牌响应 Schema""" + access_token: str = Field(..., description="访问令牌") + refresh_token: str = Field(..., description="刷新令牌") + token_type: str = Field(default="bearer", description="令牌类型") + + +class RefreshTokenRequest(BaseModel): + """刷新令牌请求 Schema""" + refresh_token: str = Field(..., description="刷新令牌") diff --git a/backend/app/schemas/comment.py b/backend/app/schemas/comment.py new file mode 100644 index 0000000..a5ae3fd --- /dev/null +++ b/backend/app/schemas/comment.py @@ -0,0 +1,55 @@ +""" +评论 Schema +""" +from datetime import datetime +from typing import Optional, List +from pydantic import BaseModel, Field + + +class CommentBase(BaseModel): + """评论基础 Schema""" + content: str = Field(..., min_length=1, description="评论内容") + + +class CommentCreate(CommentBase): + """评论创建 Schema""" + post_id: str = Field(..., description="文章ID") + parent_id: Optional[str] = Field(None, description="父评论ID") + + +class CommentUpdate(BaseModel): + """评论更新 Schema""" + content: Optional[str] = Field(None, min_length=1) + + +class CommentAuthor(BaseModel): + """评论作者 Schema""" + id: str + username: str + avatar: Optional[str] = None + + class Config: + from_attributes = True + + +class CommentResponse(CommentBase): + """评论响应 Schema""" + id: str + author: CommentAuthor + post_id: str + parent_id: Optional[str] = None + is_approved: bool + created_at: datetime + updated_at: datetime + replies: List["CommentResponse"] = [] + + class Config: + from_attributes = True + + +class CommentListResponse(BaseModel): + """评论列表响应 Schema""" + items: List[CommentResponse] + total: int + page: int + page_size: int diff --git a/backend/app/schemas/post.py b/backend/app/schemas/post.py new file mode 100644 index 0000000..6887747 --- /dev/null +++ b/backend/app/schemas/post.py @@ -0,0 +1,115 @@ +""" +文章 Schema +""" +from datetime import datetime +from typing import Optional, List +from pydantic import BaseModel, Field + + +class TagBase(BaseModel): + """标签基础 Schema""" + name: str = Field(..., min_length=1, max_length=50, description="标签名") + slug: str = Field(..., min_length=1, max_length=50, description="URL别名") + + +class TagCreate(TagBase): + """标签创建 Schema""" + pass + + +class TagResponse(TagBase): + """标签响应 Schema""" + id: str + created_at: datetime + + class Config: + from_attributes = True + + +class CategoryBase(BaseModel): + """分类基础 Schema""" + name: str = Field(..., min_length=1, max_length=50, description="分类名") + slug: str = Field(..., min_length=1, max_length=50, description="URL别名") + description: Optional[str] = None + + +class CategoryCreate(CategoryBase): + """分类创建 Schema""" + pass + + +class CategoryResponse(CategoryBase): + """分类响应 Schema""" + id: str + created_at: datetime + + class Config: + from_attributes = True + + +class PostBase(BaseModel): + """文章基础 Schema""" + title: str = Field(..., min_length=1, max_length=200, description="标题") + slug: str = Field(..., min_length=1, max_length=200, description="URL别名") + content: str = Field(..., description="文章内容") + summary: Optional[str] = None + cover_image: Optional[str] = None + category_id: Optional[str] = None + status: str = Field(default="draft", description="状态: draft/published/archived") + + +class PostCreate(PostBase): + """文章创建 Schema""" + tag_ids: Optional[List[str]] = [] + meta_title: Optional[str] = None + meta_description: Optional[str] = None + + +class PostUpdate(BaseModel): + """文章更新 Schema""" + title: Optional[str] = Field(None, min_length=1, max_length=200) + slug: Optional[str] = Field(None, min_length=1, max_length=200) + content: Optional[str] = None + summary: Optional[str] = None + cover_image: Optional[str] = None + category_id: Optional[str] = None + tag_ids: Optional[List[str]] = None + status: Optional[str] = None + meta_title: Optional[str] = None + meta_description: Optional[str] = None + + +class AuthorResponse(BaseModel): + """作者响应 Schema""" + id: str + username: str + avatar: Optional[str] = None + + class Config: + from_attributes = True + + +class PostResponse(PostBase): + """文章响应 Schema""" + id: str + author: AuthorResponse + category: Optional[CategoryResponse] = None + tags: List[TagResponse] = [] + view_count: int + like_count: int + comment_count: int + published_at: Optional[datetime] = None + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + + +class PostListResponse(BaseModel): + """文章列表响应 Schema""" + items: List[PostResponse] + total: int + page: int + page_size: int + total_pages: int diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py new file mode 100644 index 0000000..24a70c5 --- /dev/null +++ b/backend/app/schemas/user.py @@ -0,0 +1,50 @@ +""" +用户 Schema +""" +from datetime import datetime +from typing import Optional +from pydantic import BaseModel, EmailStr, Field + + +class UserBase(BaseModel): + """用户基础 Schema""" + username: str = Field(..., min_length=3, max_length=50, description="用户名") + email: EmailStr = Field(..., description="邮箱") + + +class UserCreate(UserBase): + """用户创建 Schema""" + password: str = Field(..., min_length=6, max_length=100, description="密码") + + +class UserUpdate(BaseModel): + """用户更新 Schema""" + username: Optional[str] = Field(None, min_length=3, max_length=50) + email: Optional[EmailStr] = None + avatar: Optional[str] = None + bio: Optional[str] = None + + +class UserInDB(UserBase): + """用户数据库 Schema""" + id: str + avatar: Optional[str] = None + bio: Optional[str] = None + is_active: bool + is_superuser: bool + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + + +class UserPublic(UserBase): + """用户公开信息 Schema""" + id: str + avatar: Optional[str] = None + bio: Optional[str] = None + created_at: datetime + + class Config: + from_attributes = True diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..0557eb6 --- /dev/null +++ b/backend/app/services/__init__.py @@ -0,0 +1 @@ +# Services module diff --git a/backend/logs/acg_blog_2026-03-28.log b/backend/logs/acg_blog_2026-03-28.log new file mode 100644 index 0000000..e69de29 diff --git a/backend/main.py b/backend/main.py index 9149eef..be9972a 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,6 +1,72 @@ +""" +FastAPI 应用入口 +""" +from contextlib import asynccontextmanager from fastapi import FastAPI -from pydantic import BaseModel -app = FastAPI() -@app.get("/") -def read_root(): - return {"Hello": "World"} \ No newline at end of file +from fastapi.middleware.cors import CORSMiddleware + +from app.core.config import settings +from app.core.database import init_db, close_db +from app.core.logger import app_logger +from app.api.api import api_router + + +@asynccontextmanager +async def lifespan(app: FastAPI): + """应用生命周期管理""" + # 启动时 + app_logger.info(f"Starting {settings.APP_NAME} v{settings.APP_VERSION}...") + + # 初始化数据库 + await init_db() + + yield + + # 关闭时 + app_logger.info("Shutting down...") + await close_db() + + +def create_app() -> FastAPI: + """创建 FastAPI 应用""" + app = FastAPI( + title=settings.APP_NAME, + version=settings.APP_VERSION, + description="ACG 风格博客系统 API", + docs_url="/docs", + redoc_url="/redoc", + lifespan=lifespan + ) + + # 配置 CORS + app.add_middleware( + CORSMiddleware, + allow_origins=settings.BACKEND_CORS_ORIGINS, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + + # 注册路由 + app.include_router(api_router) + + # 健康检查 + @app.get("/health") + async def health_check(): + return {"status": "healthy", "app": settings.APP_NAME} + + return app + + +app = create_app() + + +if __name__ == "__main__": + import uvicorn + uvicorn.run( + "main:app", + host="0.0.0.0", + port=8000, + reload=True, + log_level="info" + )