From a243ee7e39aa5d5da4c01e92a8edfecf7ed40a80 Mon Sep 17 00:00:00 2001 From: "mula.liu" Date: Fri, 26 Sep 2025 15:47:37 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E4=BA=86API=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3=E5=AE=9A=E4=B9=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .DS_Store | Bin 8196 -> 8196 bytes app.zip | Bin 44904 -> 45902 bytes app/api/endpoints/admin.py | 72 ++-- app/api/endpoints/auth.py | 97 +++-- app/api/endpoints/meetings.py | 758 +++++++--------------------------- app/api/endpoints/tags.py | 19 +- app/api/endpoints/users.py | 83 ++-- app/core/config.py | 4 +- app/core/response.py | 14 + config/.DS_Store | Bin 0 -> 6148 bytes config/system_config.json | 4 +- docker-compose.prod.yml | 1 + main.py | 5 +- 13 files changed, 291 insertions(+), 766 deletions(-) create mode 100644 app/core/response.py create mode 100644 config/.DS_Store diff --git a/.DS_Store b/.DS_Store index 932e278b883b22682d019e31d57bc3940a8ecbd9..248e56391ebdbce3c1caa7194efb49e94c1b8e97 100644 GIT binary patch delta 330 zcmZp1XmOa}&nUYwU^hRb>|`E+$jR>oSOZu62LlEWU}H#T$Y;o7NMp!kNcYUiPfp6o zPXdYyFfcH71L=9V)Fzb|7eLkSn0!g7P+g+B+StrUN5RCvyjDk{+S14X$Tl{st>xqp zRo1r-iqFo;&CBnaoGmOb3o;qS=Ca_Tyqx^Jbf7rn=3;@HjHUt%#SA43MGUD7i43_6 z@j!MeSVWH@1IW&0$U$@LPmp6LI|_MN3c=OJqnVMzkOS1I$56mfiK?3kWbwSoYlNgW c&kz!3+E}`WaWlKbHnE_Oc7*YTL diff --git a/app.zip b/app.zip index 62f0b2c5906a15d076d37a13541061862373fe2d..c146afb98f92a632cabe14e219fd7b5b7721de23 100644 GIT binary patch delta 19537 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¥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 z�?A7790N9Lvm%L@(6!rY$7x6VH{P=VVECvbiE_q*^sii4gh%yjJ8C#f}alqf~_Z z^qp@>rv$^I&I>sgH}UfOv_ZvY&C`2oyV!!)*8S!=z_T54^R{8zb>8RZwz3noQ6JfC zpUm}86Lx_1lMP*Ihq{+H<6`Be{>DQ`w3HKx5KN{83PvLzDo>vkO+L2-bxs( z9$syU0D$4DD~qVC-e-ZX#=H>dVLjt2#Qr`D$6v>$Y})3?2J))dudIOS!x2h070M!B z?O@dl@E8=!?G+jeNtxAHMG`*bMpK}}z9s_w@w-wVBwH%cm|sJiWTftZ&?p9Pn5Z=} zEb4p&I;5jOa{rR2M9?IQju83(^>O7vO&wwUkw--as1PEc2`^DV5afzhE&-(ljbII; zSdr6mwgrniMWiJh)p8W9Sq=r{NJ5YZmWqN=(eXgkcob2j9-u(0t=bVU;sxD(^o7I` zU;aq;_^y3B-)v@ozpp2nmhIRa)<>sJgdh_ql?< z0vg1^mFN6@uF843x~DRqneM9oZ7~1Iykm?I{)L#@FQVIO>vJ)n;>|?>%8C9710k#|jdQtrbH1XE{0Nn=s!CXNaMI6vKYGH0yed zyu(WAINWM9ILUD^?#kQT&^+l#e(OkgAbVNc^S(E?t#5ayrwr^E?kXIWOuOZ+8&Vf{ zvoSy3U3*w4Ke4AhU<$81-?lIH=-Js>_qZG}L6>wj79_diB^}#O+f=RL>jpdfd?{$P zFfz>QDu@(Z>q}o8r6ovpGisMRmhAGYmz4H6XALGTv8zfi;vDl^on5l=fs=tJx7_$a z^u=#d3+K*K=1z%y#p=?=8|=3hETS9YG+Sft)A~K&7hyw=`%rVovh@SM_a$zzd)<4c z?0U#^g4pCT{H9JZ+dFDR;?!e2;*uaJdN|j+KxC@Bh5LQaXJNQ|M(c)*$GqcQ;jnJG zNByP!m%UsMZ{Mc;f$lz2`)@D7x0iyV?>G#u+ppibNxwr{Q@(vPH<^9yfd1mMT4kj( z65RR5UDw532a7WDotgrAmBsBW{hVan`_9@7Wo)pXJlpBEo15<)X@qzPzZ=2#XX;FD zE*c8*hwvVhmB({B%-8Ua%falu`FT9Cv%rpuT_j|>vw{V9yG3KaB`mT=j6tQnszwZ~ z9|PbVvOl`gMb(dVbxK&^AkF~YmARBWNUtiTev<%hmuhEKjTlrr2I)eGZ(+5Es-M(V z!TK20jv22Q0cbU|RV5-|sR$&>MpKx!`wW6H3`2->CPbW5k0et8l4~qfGXJY(BZjX! z_m{3AlLv0pjI(?p+n00^$^b&dr0LUvhsaWc1dLW}n~1NnpXJ9EI@GjU_F^x+`l?~hb%`uS1v{k)R#F2e0 zVbTH#VA#MgBun|I^Jt+h9L?d^GHEpgNUXKcpwnteIp9d`bUccU0b6r7(u}#3z+X6F zV8)thpvS>Xby|YvR%mnq`E@f@3$_A1v|da05P15h7~n7TBAsl8?I7ORd@nz9$_sKYnP?4=kO z^0QWv71Z~7IUJv{YD{+hSCc?by|HQ@+N4o34lt2Trwy$FTv-CXJQPixPniHuWdzO) zL+Tm8X74)u$3#k%9W{r8SP$wTamae0b{i(i3{QeDi=#(j!{Il%$q{}$q=88bPX_!( z_JlBUxEve=8&n4rYU-c00c{3^DOceMA+C_L0R>4Li#9_WcB)|%wsWmH8@jE2Sr3nw zYHsVMBDH1{K@TYf=zUW8z)C~G-Aoi1;O#Texb#N>p$mWYV=wdq8YT{rRtZgWJl}U5him-q Xah&h}5iEd!VO;o0gntMWm8kU}h#>qo delta 18902 zcmZ6SWmx7~(ynoLcXw~x-QA&ahsNFE#ogWA-Q8*2-QC@xfd)?RnZ4(mGm)!u)yi7Q zk5zf@swC6LAg9|PabBR*I8%vr4u18Y8o+-a(05W>D7)LFnH)>-_q ziSYzD|MLGi^FRK7_xDb$2MzqYzjtCIn9o1QkU)Qya4Yh}7I0G72j>op$3OcY`>(P7 z@&iNA|HuFLy`Y}y&}#qH@2|xN3J3@Zh$vC15CdQ?I$$BKJz(*db#aFU0tR~m1_A;@ z`*T{h>V_q%5LVBO3LNbKLs4XahCdVmNp)Fr28!Z1^Yv$a;n_7ePkA(nCbDdwdOl>u zBCL=!a+9YaJ>ThsNuv7^EobP~r7g}PC+5WEB;fb)doRt?u_8Jo+&fr2*G?f^bm|0P zMFy7rSCqRg!5xn};=KjT^$M|phcmLbeKwJX+-nB#s*AKHDgGg^0SqhS%gMR*a^WE{ zh7hBoeV-}VN&!{X9_owyB@Z>rK}N@5&rIQhhmnZEwohNxDP@a1J~={*M87lBV5`Gy zYZvIGqpfz!NryosTDwl~rBQ{^*60xcuT0v@SI(ncJ-c-Fs_1%vuycQpBvkd`lX&Y^MIbEf+`poL2Wa1!E1(ibv^8uR?5{sg_fPHWJ0LLLt z0-5etkXtGDY{PM*AAX+DUA%g^>M+wt##RbcUTeAsY6wIdGEz}CioqLdYYE*d7UWhWs8 zxVOwO-GThL@GlL|4}qb1)=Z4#2$%}JEtjT)kU|t|KNB>~?2d+BQQ{#~g}O+#^S;O@0g>Ea@Pyt{@c{hK zcr(2!Vy9e1U!d8&2Smr5(EEK3MHxt_pym`(8PI>nprQ;Y%%4bu`m_EC&%{-jZ?OM_ z3b5VY``_4@&^x6#jR>{$>9sV58NwRW{U-d{=5`IT1tTZ^50WWC?<*Ss8kU z*vZEt#aj@?S5s7FjuYg~{Jpmgm!6A&&dCW+D}iuwXiGl5Q~aWL^=;mO~Puq2D3vcH!Kb`j%shf{hA)sToan zKg=>6jde~%5)hHvF{b0>WkGS+BL})<+ZeyozcXF49@rHm;0ftmwpSHn0}f7B)!LM* zHm!$qL2qNWlxTjw*U{lJ8wYTap+P>Fx3Ne_G!d87fiGn#``|sPo{Gk{i|!lqeU{gr zt_Ut&lI<*GD#lzrd(m|Q#IlIS-5L|1ldXA!YfYg@Fz=6SOx6G&cL<=d>r+g*W`bkb zp0#LS^0_*_n+7JtD|Uo4qZI_EC18NH$Dn(_UT?s`n}^S2P-w}tkJ#QeEkf&A&dC%LDzcGvGtP=+r18c=olDGrk@hZ zJ&~R7gNjTGac@em*6CCkCm-H>QMcBjkJT9@SmR>ut0ac=qyUt9NE71d=ZfOOhumdU zCDWg_HDIlZn*%+GLFIyfP7RsNk3|kGwvH&y24+W%H@FX3R)brN-DZ)6TcfFUpOV8C zU{R`Vf&ED3d4$I|-u?6fG+*Nde!s{jkg)sm5E&uSAMAv9+~y5!*-lWqd(b^YbywAp zP$CXVV@(kCPX)w*sq&gfk+o_5P>p~gA=wxJAUsL1DV}$>W$MG@dCy8!%c6<+VVGlv<$|(1mkoxir4Xn&+ zvB-yyKF_L3D<(7$YcdJAj3hH}=9^KKLDkAl0pHWvJt`o~C2TB8l@W^8aq21{obxRt zCO*&9sM%X{m5d{KuMou+n^sbDp%xhN$rJ=s_s;hxGYtz z{tRJJztjK)0)1d`(jtV?Dkz+$qHRrKZNYLBiH;4G)h9{6n-acM#st*vaf_qh$M3|s zGU?5C_C)UbyIQ&{_}@gl+J4PET64J-UHJwy%6N5EWZC8fDr_6w{?5wyczks)Z)z#z z)1)^oZAN5Se~VB~t0iqdGb;auRs=QR9_*nw-K`EtJ#HFmmA-3E&{xHDCC(b}2DSfu zbq}o59;JDivVOh9e?+HB-I+{sBCKn(9e8^s@eXRl+g_rSIr5TOujML09qq`^;-fN$ zEnZXT(;)0p#DsxR86#Ae!+oKJo#?op7_W4RV_FvBq$}e7>T&+1Lrtgt%tW|3@Xq_ z%tkB1HRS&uKJkhju5sa;A^1O+K!}LL`40*+k|qvuPymWTsx7cc`Yisk|0AkbI97J3 z8qb}8p`|>UmgR42>J{oK4JhPhsat~UeH9f^Y&e$}CJ}rrJS?_S^fG-3NJRc+hjT~q z2?-cY!DLBIL7a4eVHQ{ZBgV(g_J_8IC_{}3_4U^{6SL`yoEI+C`kGNdMGXIn2{rkA zuiEbyh-GJbHMJw|`BF5c48iBlZ(^9HQe~!dVVcX07OIQRFqBl%HFT9&zLIKlVp#Zm zLxhApKKqBc4X23;u%)lYgt>=bTEFQ0RFR$cy7CMmZ9p!(k9ZNA$aE;^RLWJ`DJ*b^ z-61OV!%VXjh}&q|BHZ-=Nut>&3ph1uERS66qc-x2qMsl+!h^n_{AnFJ{cL(D=U9Eb z@sU|C0kpwm?2B4J+rBtp%O$M%D<0s}Rj0scJDo&Ex^1Hu_uzCtg!(rzh0usf4Af?E zEpM47Gm#RiZl#g3cpHIr)f5cE^5nl)%#fS_$~DJGl|UeWSZFrS zq)24cfFQ<9sNP2nJ7m4Px!q|yx#l;zwYJt9>RGyNxNT}&6OJrGPYqIF z(t-`~aqWZNLNDoh8%_6YeqH<5_gHW1oz~nuv!D3vUQcmCY)Jvz4sSNq-;yRzr=_#A z>$%+MvE@`_E$~1Byqty+H71wdhupjXR!3OhYj%x$x=A5RuvSzKuVaGNtQxgIS}aE+>s1Y{w} zo?mq+!rkmH2o1cAKk8pirZl^u)B7RwjO60U;X8FEKqQqbmpsDmSxMDFP!%ggD5}Ot z%N~yX?=EpS+{{`hi|L(w2UojML7EHUqK>5tq4fhHfVC=V@s{`hCb_NBn)56Ij11 z4L$;zg23Rx)2M04nw*niPl`T<0MG0k4`t!nIuyIYzdpA>|5oF?@1BsHKm$zDzs zEP6bFNLFxI%sx{WLlRg@bk#7cdIWTd2@*QBE?ktWt}EwwFometY$Mc+5Xy6)%6!RF zg)bc}My7KJshvn*d|X26sKQwOhe%A2Xl|+{c?A8RMFskBT1S3&@MLHfgvi>qt&S~~A$CgMv}_eQ*Ck}-Z6BFpC7 zT;b^;aT|&~s}4jKYv3cn&=}I?&ImCi8)!A42t*=yQQ5j<;4pN%w&`9jHQq&ExO?hq z`#5m=+BR`c3sxC%PLb(d6lbRXfh&B%hapm#n@S!ex^*V&JUivR6L*NoRXgg zvBLL;ZA(#;N!GoHE-Lsl6DsN2%}Vy(mzy zWz}leq;zGPVsft6Rt}#OK|J!X!@Fw$YVbr?;DTC;6nYYgF|11m+u$Xk)j{{*^G#Lr4{Qosupe;J5Th)=_Y|2>mFKzC zkk}?gq7U;CPT}dZnSKB%7nW$xudW8Epg<%|JXLN5V>3b^2N7WF-Av+kMk13Z%Cq!Q zKvo-NG`c{`6{yzhqh*JZ!w4q;kW$01)&nKQB6eT6biBNJj-L1vgNou=@OvsWl@IKD1}5_hhluRd{JCK=Il$mc$(WQ`esiS4~6 zi&WMt;AC>$Rc`7}M`aWAn~u7GGp~~|k;Zk8#7-^+yX`M)G$P|1zMCHbk{iSPxy{Ot zfYxH-7XfYHNJt;xOt#MwfumimHVk%a*3H}Ns8G_GrFYgto4;OLbB{xWF=V)K>vwTjbb`ygBLe41|=R=_Mu2;yJ;CQ#JAA#AUCn0+=uXc+?;qcl_c zM-X4Bx%E5HZMZ_-0e1KK)S=kQv4I8Zna&^CHiTMIan-=9WyBao=%RJ6+LYI8SJkdcH1h~ zb(_2?I=HhR?fv~ii(ZYh+L=z^dG|!_#)OHN)vRx^V2_=xANt@e@K?sOY}gNHYixXh zxse}K1e^}+&dTF-Ogr zIGZnUx0!O4GkfS7{bH3N^_7vSR@fsjoVhylunUO1NXk$n?4pbBzwlH?XSBZ*8VHDy z^uO`+zX`DLPo*<)T9^^`9|rszBolR|;2M)eP$2#rCP6>6s1xa=Ibf%=do8l^dM*Cg zMqBA_n13}es0sh4;U64*S1bMhMrb70?L+zxivEGv|Db3UL?XWF6|VB4C^8H>koS)6BMQArob}S?WWSPvyMk zv>82b>7cghU_RNd&U5!n64*Lz0XjT&nb{r!$M4&|dRg&k*imKkO}l-K!jkY-Jc0RC zYyfFO0*N{KUljeqPVw(`G2q9tT?k!N`%MlO%5`?l!|@&sPTr-K-Jd)v;&(XXXG zRsF<$=n1IzuYpt*YQ6dWHRL=@L|u$BZ@OtK2mc|NvRYJE@_OsZb^_ef|C4pL`3c|- ztJf0nx#$tZy~dI4go#gnu6(?_vp!4m_|=OQq?~b#^_|L`6+Pi(9Us~1sz3f4LE<%n zRJ)K3g6zs}rHm=ga2){PvIH5Dao?oCKFPs6TaZG=)iYcI6}geL;WwG50_6hA1Cbe$ zAxg5ODRPjZF!`VtedqE^r{~@M^#L$4^g1^5nmV@DnhAwteHAhY1))9XO1hK{JQiqf zm8meOYoD5gC^O#412*-e4W6EGy#`#);i{zE0huz>Ogwv(xu&+S#+3EAs*siKhMgLL zt8=~+{3*;{+|#BC@h5^g;xQ{qWerdB#zAn^N*@0xTMYS%n#hw4xpaJq7pts?i(Gu` zR9|;GHWo4{1s&lOno}1XD;rSb_$Zs52zt|l3*>PVc2&n%-$Og{2kc*YAfz^Kb}R@G zP}QGY@c;6_zmhb0cZS4t2e^MFYJW}Yzas+yOzZzjn}W!H<~|wV7yrL&n}6B=NyC;j zwC!-k(0pHNvDk&b%S?y72a=OYLt82#tA#B>s#ce5J9O1;vw4jLQz_5SQkivB7AR&HBswxARz=P-Cgu@u54e)T9*c)xR0mCDE~ir{ zHi3AamTIXwe zf+{=LOgU3OF=>!*&I>h={J+wsy0KKd;#2X9<$b?p89M#q^{h?;aNP?Ce+wEo+Ay?w zNpwOTb?YHNRnd-F$joX#0?Qx_n?!jeNb$yQysmICtT-;#xf&%b?!RNdC^H z>s}I8Lz*<7;8WwsXQN;U`$pB|$gIKvj3z-l&PI8J)kbdp!(O3G3g4^?Ig{}Wo>E6M zQ5Rd-M+n939F6yddBn~17^XgOK7F@ck?|R0J4-oGdML3elu9u;(E#Kg(F2%vPuk#n zBTTIamgndl;Ej@w3SgXaZ10UwH=VD1P9txCYrpIrwy1y7kWOmp!%oFV_8ribZ{sdEyxVE5s6lP2&n)h@1>V0FV@Vx+#0_lxfwZ zNJZV+4OLE^63dlR4U2GfwEaY()4*fbZY=cqz!We803-g#;OLv3OUCfWbTsXrn?Q)h zjVt37;kC^jZ;X2Cz%Yg#Qg*eS2c2Hc3t36p@mKxM?JTdt%9ur0$M%9xheur#j-FkM zc5K0wy8Dx!u*LAL^E4qdiqcM{*;V$IyfLxP78E0+TW(EgDa<@+`_fHZy3h%1bQv5(T=D_#nRJ!Gxv2+%t?j?v|F7Oq>6L$(%IogPP8`g z7x}Us#42Ma{EX;@I?|;_PxormrmjxEor?ofMv}o0^Z*P3cMh%rD*fA^H-6Z$Vcf2c zW&6!chIm){FWy;@+*Ttnb*lhOKq#A6srri!=1TZ3v}YwQyV|-LaPGkBAxe>bx~a*PpcU{Lbr|UGMIeDtZPhQ`jpC*o@Z%nBn2}5-afJ*JO z4ZDBX$53oAhh_n_>xHTFVX~}^^;_ZFT8i+B&JW%MjHlDm5VBTJ2>i;|i5s!S|2;Z- zbT8*US0&j&_sAIp~DI(<~c zui*RA_A)o}k$LlYv&H-R8m_{=2uw^*cp_0~8ItSxl}s6DK#`Cg8K~*z`x;X81|HmoBu9_ek^yI7(JyBU&8f925)W~DFY z35wPIFPxe{^r%VkAH_|YVmgo%UiQ`O;uFcuD`!@IL^RUqkLL>D=_H(_R4_g8a10Ra ztY|RDTyAgqw}bcbYfnSHo5F}$#M16+rh zk%v4!Hx~AfFP|T^p87t{zh|^R7(bagc~O}85cQK07cA<-F95w{q2$AP*WtB7!_eZd zY{Zb2-^@$4gSNYrE0Rd~#hl~a(^S*bn4IE?Cb5eEpvpKA$}`oPW?S1=+J7nxp8DSF zoiBnG$@myum@eKo6~@N6(41mH9($|W{iOc6^xf>LqUAfOiaZsD!9 zsJ;kdKi$RwJWpdo)5a$Ms7U|t`7g)xQI$iII1!VlF7MM2mz52c5E2SL|7raM7?$Go z;@|akQF<;XO3xY~!&a@?v$GaiftOFRxRCzMku2b3n}8?~>91Ya8;)F4TPmoHWFW6I zZp1`fY!Woueaj{&x`_Q)A=^|82RO&q43IuIsu|_kB@ae4AVPr8V($ZEN#{W=;`eOi9}(fSvF$cNvLT?tJ>WI#7|&qP?TZMPT<5q-eHtsIej91Z4xW*8NB z2qO#g0H7~##H%R^d3$CJ5z`fDsI6LBoh;`A0hNXHon8>!OQ2j~Xo3v0fr<@k4uxk? zU0v3+^#Ibl0xtqXJ(cV%IRCp^92I*+!ABLG*Yzk?vgwM?X1(|1w`dcPNJ)t(qDL_j zjGvf2$dQisgL`pw;P^VRW4J%s<66R}-B~FSn*mAK=F`Tads7ms%SMtT-yF@7%9X_gdN^yhu<(tTK z;uOHlxCQ-JT&$oD{A1bmSwsZnut~Vcc0mgZBks&92F$UuF)D5G5sjx!b=NqLB4Ks4 zIGhwdU5@cY+tYo;2N`gdLata_h&W}u2_HoTB`F2iPJUT~X-q#|1__1boZ>~C{{^oQ zAK7}j8cNYRRZLZi2_0pEOxrW+WBf)*I2)ie>0k`pCJLGk=$ZvM0_gWb**X}|9+uHk zzbun!LAI^rJr!YVcHTn6bN>!LX0Tk8mLHSn_F=(fGqe*|?WVqvOO#ZK?9yzNs@Xz^ z@@p_ns2Ajm!p$d}FX7#OQZnjN6z_(B%ZPw2Tbn2!c}{b}h*p5H(0#@sr^1#13M63m zF?JkY_waXWp3l#{s#AXty4n8I<6Urs&;|V+oZs(dFSmpJA8x&{p>||=HJU;!K7(xM z?9zbrVzoSF@{$*9J%(Y1Vu~7Yl~hqwb-N5d8~bh&>}ZSI5&dua!31ILH8=y8uu*Ns zD>}c+2%q+IElx=aL2(zTLKsq6vUF?uHyQI{upasvItFx|73{ckn6F)&3#%~MMi z-$aTHCko<}dm`$n<%-*qi&dmS6XU>D&)XXC5YxC%HBgix#Qd+>aQw>az}Y}D#2&a< zMd;%kkBCjZDmr~3T)aDsW6$3ni{e#jmWru*mFLKh3fC3X{SF#V#PA6+d&>YIy>mUH zzJ`%niNVoBoG14eM>Im&%}1sI5_iLqpz+ba|@Z$xb4r=8L~XAkq=Yq@r42_(zxVC%ttB&shwAl?|;d2ra4!=K- zVV6K+7~dOR2>gX_j93y1`f`6tXFRn3&2N7=EfFz?ArUVC9`+wV`)N2NbF|34YSIOpHeklw z$&gw>-ziTsEDOP#C zAy03PSEYg1*tAij$rbqNvS~S;R(GymeRve*Ho3niw(?_9y0OG<6?b4T^U1U4JgF)| z;>#HIs+n%5hWAw1$6lxe&PYy zgk0`Sc8eKN`?{)K^4w#Hr$nC@O&fQ*`T|267Bm=^fx9L-)Y7T{k*;M|Y=SWf14gV= zy)nYVP}-}EY9q4hiRXYNF*v*&w65dBt7L?7^yDrORb}DuVEYdAi|gCRHosQA`OKqN zB5LrMz>NHLW_rdpfCDu}?g}5^rd+Yc^La9GUCx&noL>HwL#8i9`p5@1(YQ(KI(L76 zv%%J>l&6m!q?})(Qmm}jdAZi;M$yuJM!ysRuD|Ko{KaWWf-KRx$0{gFljOsM`$_CD z69K;hUO~so`xC9~XTM4&dU-_DKI4)<#H*)`D9%C?*AM?Pz;J6N9^P~I;&Q6A*;H#} z7M!2oGf5A20eH*^KO2r~xX&{Jn|CKO>!Hyy+psYEv`J`4Nc8gg)N$>J)=4cm2Sd~J zHUY{UGley5)3RNkChzqaTz~9KKr92F_E%T)r=k591DsTer{|xK4l@s`VKp{RdaQJP zXSuy@$__vd050q`tvA_$G1<`**&8R?gaSYKdqB4?WIl;Y&4_@c2EBctqjx-Roo+uu z`@1))GfzSy&=($}+^RE1qkU|GIr?>&R(;sueYKrIaqP`@ zX7_znfNxLNJ!hba!;3v@KbSQkrgGRmS$j@)52NBerY1^mnZR;AEb2Ei(;w=X%>4V2EYfNMcKqEC7*v9<3J%i-A9Fd>sCm!g)_^` z^*}_%{tYS#-x54O3m0gL9y~!DpivaTm)L0zEn0E|+OA2oE(@X-rh_$y)*}sy#E-Ia zn_(_X=!akwzIe0+x&Zltd-FS$HFVMFt?%a{a67nq7iuC)=b`RTF=c(1vj~g4djO3{ z{y|q9d^w7&E-geYHFV;~K>9gMS_m5_`cOEo?gdB!mZZeg0}fe$1wD$0wWddY7F5SK zQc6{S+5$YHSyd9)v~W5<0%5Zf*(4ZUmo*Pl8$nR#x^`zyOHY1E#ek>;(R{)mF?GFJ zC00_gI5kerF@S8yX7T#riq(}@bU>GyVn~Zrj*ni3^jkpkx@Huc=t}4lTs>NYD(S^3 z1tJ8SY(_o|B`SIxp02Scnz*OI93K0yzRQ&;Lqtrpv`crHHzLn0i0(!l`QWi(dJW`G zz_(9oGtDZBBtv`-2=a>0uxDd7t)Kyln0x_cIdxLxGBy%h`>efh4S3i4(tt&n{70mE z0T}WLj;n}NXs=k1tALQJ83qsRkP*9$<5?stF8vl-^?X|pl>E>&sx^6kOg>6daJ~TY z{*$2B32x(p&8dtq)Q5fcAu3lx0GO*mh62Wp!Ic@9kYx6^b@k<_Chhut_gTnx=6-X` zjaCh9N?-4{vkwCs`d{}+i-3MMo0$Ys2W&*hLB9%_{N4PHswxp zjt+T!>hlR9xCS8vrn+`4|C#kB9K*Iv_V4_UD+l!MB)m{nZgZ-I3c&5>q8sC%H|fn= zmE+e6x^8MqALVCLm8WH%%cnVp#$Bo;^Yeo9^L@g&K}a!|?gg6rsI>RId;%3)P)#2m z#q@C^znMhvn2Ba_U#O%}HPgn8TlP!h-d6NgQo;66+fFq!$dDqUnz3 zd<)fkWrwy%ztzWX@l7s9X_=^43nnNs%nFAaniIxhf>?o>;aDNz!i}vAl-Fv&pK)U< z#hHE%GiH>n1ehAcTP6#WC`O4ljpsxS$J^(mQK=tJQsK;?v;cesDNJdSNmMs=zPcJa zVCIwKoGuQ-m(Lj$-}m8X42@f_2DX8=BSXP4G}3d4{Uq$mdoZ2rtVfhkrPb%C!7XZ1 zi}#GHDJ`+YWGU0TTdeOa;vQ>$;)Y0A3@?nP*&@TPC$C-wz~96lh@P=XUgH^|tc;9T zDn2>(sTAKv+5voaNpXP54bG6s@hGEI6*HLNdtVRTkfY-bXt|6S{%j$D?sE4 zFaE(Ln2Z!NJ1baba2`XM9f|}mG|68=#>uBb&ViF?czYg76cYXMGn$Dk@S7^MbvUPU zUY)YSNqmPxM~*Cvy@fob$CW`0_KMQ3vh!-f-tvk_@S-cO2oqvDj)%esl%_*qPu{Lz)H z!gTBHXFNv)S}MY9qtgHkiza{04R@;>G@VNF$RViPtCqc4CWW**Rv8K+x^wNn1!k zh7CXpdEEw`K>$=`4`-T)7hmZ}mHj95LEt;(usNm3yaw;j?*$XzKu2M+F_fz1d*^=D zI(}A}mg=FkLwRxV^^l`ix0tj@=W1y?xfiMsd)Eplw}LuLEO8kjJB0kgg=R%0{ z^rN~nB3}cOWL_0IqAFBCenwIK67Zj*5fVmnon5L)JcpdJiSmr$T)U+pe=v|3JyIoc*k6%%Ftw8T@SeYr>TvHPF&0BrV86Lo96 z(t+PLK0w0ZUMFb}?#Pq-?jAqG$ev-)(~+K5QwHbe+Br_Jfkcw-ll|j_fb+!kKQZ)x zmau3V+*7Jjvbokiiz#HeT{HQ90JVmn!9(9RdMn)Kc8D#^uaVsx$A&9!09N{KMrU!l`Ov~0U=Cus)oR?H(L-Vrg%FZ)zo%^F$LatsvADns||AdKsG@k&Bqvop64j9D$8-{Fl?9&j-vzxyZe zrQWB`1#^2mo>a@anto?*E?R9ax-mz)Ofr;unPkrm|)WBkQ=SeVU01)(%<{BliCz_G}YN znp2LN5aZYUWWBc&O@M`S<7v zkSPRG5*ngHi4fDM0*XF`65eDr^TOvxQjQAZH~=j~AkPjcs;xw%EK>7ocZIs!?5hKs31V9rOgRFAs%^Q$5c3n<-$k9n$WwhOb?tns|R-0%>H zcf?9zi!L6$(yisuS|ML9*8&9cw3QrvGbxNxSo~CX{>-xZ4xmyLX_TEUW!=uvWEPGj-Ka`fh3=5Ow!b$X3594Q_`+!bpX&N-?=GQd8947NG7M zxwM6}K)B0!ITp!jU9p8UY&S`zKS4ZmzRy;3%&NSIg8{5q9yO*yDTgH~10zBGh%B|M z;IMLmaL^}4qh#kH=Tl)|(dXFQlH5s+n-t>S133h!m{LHPBIk5c-X*OoV;u|PPGTe{ zB5<{eyUT0d8&P_WDJqF9%7wTm5)6$9L!@jx=ZsLE1`h6 z6y3^-7NxH!7g%bKdYhclDDtIPw0$>+@GV1K_T*g~z8plJ&#l9~`U5igszU-OY`P3P zXfX`VQLKW|tRJ@2725E*SRe>|sUm-Xy+zPY@W{Fa>|@n88RrpYB^LKa7yx=HkKzR+ zjtoxo%!D8kuozUy2w1|2!`(>VsX2=jlf_EVUGmg?5$cT-D}+&&p!OQct8K8>Dsl~j zl(+)F<+Kd*M;kvmq0zNfya4Ao@fh9Vxs$K}$sMn@fE=i|V-o({Y9M1yb?)5!k_?1 z)e)@m%;I+W_k*dQk$~WGl)L3Zdi3vBn3)wEqM^L8Me#^j7nbU0_Jm{I#FWZY&F_7D zLEVS52$9bBm%YH!CK04A2-KP~*jc0TB%MMuo5a7NSv|o`XW>) z28?%IrkMc#do_qTC6q2(Ly8uVvp3N4e27X_4VkR+vh$Pj1-AZjxiW{05lao^3O0|b zwt%9K@{T`$t-TQ>XahJhtvZ^2&meWs9|9@_9v{3L1;35*mHJoIJ!Qht5J^fxzyq2s zX4mrm%pGZNi&X2`sY9`zu(H|b$bQCOLk8lAnUVlxz_L{%HWLWc#&@P}vwkX+Uqhz- zyWt!Tvw~zvpHaleae{@WC;C%l~c`j$wS1HAIpV*IITLeRSO^hxh>|N zZ*;Nx275oB*cim%v$3i9hnGkUZr0vjkB2d@Dgm(h;Im^mK-NFN$omCT1!WUqJ*C0K zp&>xXGBTy+0EI8EBQJ7L8s?UXjfr@Mmi1OPaLxg z$@D#~Cs*5t!az9(@M0ryW&5Qa13R+|SfCS10!ZPj_v+BW`&o-KLh7GALm`;wJL< z9AqYg6c&9W@9vpo$%olJSB>u-uuJ?olrizMg<)oPAy8ICLBJ87uX~LB(OSMIUjovq zAq!QpD%Hg(P}3unz=LN8OU2`>9h#I;9Y=~ti1F+_GEq!Z$AT=uk)}UJd@K{nM_Zhj zczB|s2+(Lim#EN zTVY@n1NqvM+;YqhPSG(dWANYrCI-oeg8HC;#`!+`P7MpuSKhC%)Z4bzLm-xIhFj3|Od{6p&f=l*9k4Ul1FyLpxl1kFvm)nP4MDWCfnK36C`u!_hof699Bi6u9udB6o6GmR2 zqHBD)6#a@154?BO_pcJrXOQvNyoQbF?UGWz@8%hm%QlG-UWtUyW) zBA_~fT5>2*L3XYnZt_I`V6hESHZYIDj73QVfV>a}KFUBSr25X_)@@bkvdRKpiZ^ zwAkdFsx_n=VxZ@WrPpsp)P^Ey_gU`!ZVdB;kHXO`;X<$-?W+ZINYcU~6h`HDddesk ziGHXF0Ve9r65tdC)*L~kEf%^)(4EWY>xZFiw6)wYlkT?4y{h3@Ku!v+CqzuD%1zi-5YXEY%-IZo89y6mv1xs zBi}jalr;1XYqc$>Jn1&;O!&>KD;GZ8AbZwrj(QRnrYqv>xoLj|7+o75n>O55n5v&q zGn#j!>FcK|7E6EcM0nEOghNfq@mE{gt~?gMn0wn{-fQPA39Y{VCIb8Hm%o%{XP^7- z^(|ScYxFz4Mya$q@zFNPWYqrlxF?FyL7?>UJaTS^%rj<5A9>MnNA&hXpENR)j$PUD z;{JlLCmrn9IqA6IUd1r)acs_v##sZLZ|#O7o2uP*&gvG5N*adD5B&O*F$hZbSt9c1 zJ**uZ&pNur=}4sw)2t?XV_$A<*Ja^({~EX6lTYvR!wyWiOGfHD+g|l5VpT^up07@^ zL;5UgTdNGj>m3J6j5Y#A<2Is7Pu?|TqvA*wrF03ri2T5>JOuK}ojq02rb$DCJ>CY# zQ&aSeBU7*S9`xj9u(po#GO~O`yOz}@&#xQZ{OnOrr~jly@Pj?ViP>$cNWAvR+r`*@ z($+5b(#pDh4t1j@(qJ#%@%l{$?GIWKew(&#TfZbH(J(MAF8z-I)#ul5T)3M2FIh4s z*z+^{!Q?)0?xi<#Y4qALi>Nebm;QU5@#6sle{Xbgk9f2= zfwMaCZ01NIn{a2Dt9b`iQA?%Hrjiz7f#MVojv}@x_zz$hM^YSd#WyM-hs?q zAyd<;Q#bXbpd&wzqN}IKx!_@eK1eUfpSriCj7X5F$tJmgHZMV@CU7ogY9g1r(x0Pv zDRN8jMqa4FgPKB_<~?m{@lKxyBi_1c$ChrXufKXvi8z?{{ z1qzCc6|xkBUI0)k=F+260o;+jg0_N{jq#xzK>a9Av4EP^Fp0!zS8UvF_!1K?j&PL3 z`}t1C3^c-4c%_y>g4e)e0>5JlDi!8H$Y$wiUs3Uzi%C5h=r0b#PtK$!J)LPuS2px4 ziF?cfhtGyG8Z0p&AO!S~Qz?nV|CBsSx8orMcwTJ=`gt0ldJ7QC`fVw}7$-0gLt0Wy zYmHP^PszjKa8Mb6f5xTIU8D&;W2z3Ixr}5?=V2hpYB{)4WDM-eNk)+)2D;aAfP=GU zH7>|2CvmY8xKtj&IK4zYyy^!+ld3h+RcI?N2%U0@mSW(Vt2HgOfPxEDI|@ZCsGPnH z)*Y!aY;{oLHE10N8l_3u=N!pc?1ll^dLfnaWq5D6L7h|jCLWy=RBQyXGl!VqV+`}K z#V~7~=rSB@h%Y{D-SZ3N1_PsRF-gytYGI{n?cXfSSrcJ3r;7ZiC3abd* zCW1x(0WV=#4HFYysh=`pXrj1x_rth+kSJx+Q>JRqM`TSW zEqcIgopcpnf{rR{njHXH0w^v8(k|&d9Z*0r^({W66(As@6= str: return hashlib.sha256(password.encode()).hexdigest() -@router.post("/auth/login", response_model=LoginResponse) +@router.post("/auth/login") def login(request: LoginRequest): with get_db_connection() as connection: cursor = connection.cursor(dictionary=True) @@ -24,11 +29,11 @@ def login(request: LoginRequest): user = cursor.fetchone() if not user: - raise HTTPException(status_code=401, detail="用户名或密码错误") + return create_api_response(code="401", message="用户名或密码错误") hashed_input = hash_password(request.password) if user['password_hash'] != hashed_input: - raise HTTPException(status_code=401, detail="用户名或密码错误") + return create_api_response(code="401", message="用户名或密码错误") # 创建JWT token token_data = { @@ -39,7 +44,7 @@ def login(request: LoginRequest): } token = jwt_service.create_access_token(token_data) - return LoginResponse( + login_response_data = LoginResponse( user_id=user['user_id'], username=user['username'], caption=user['caption'], @@ -47,71 +52,83 @@ def login(request: LoginRequest): token=token, role_id=user['role_id'] ) + + return create_api_response( + code="200", + message="登录成功", + data=login_response_data.dict() + ) @router.post("/auth/logout") def logout(credentials: HTTPAuthorizationCredentials = Depends(security)): """登出接口,撤销当前token""" token = credentials.credentials - - # 验证token并获取用户信息(不查询数据库) + payload = jwt_service.verify_token(token) if not payload: - raise HTTPException(status_code=401, detail="Invalid or expired token") - + return create_api_response(code="401", message="无效或过期的token") + user_id = payload.get("user_id") if not user_id: - raise HTTPException(status_code=401, detail="Invalid token payload") - - # 撤销当前token + return create_api_response(code="401", message="无效的token payload") + revoked = jwt_service.revoke_token(token, user_id) - + if revoked: - return {"message": "Logged out successfully"} + return create_api_response(code="200", message="登出成功") else: - return {"message": "Already logged out or token not found"} + return create_api_response(code="400", message="已经登出或token未找到") + @router.post("/auth/logout-all") def logout_all(current_user: dict = Depends(get_current_user)): """登出所有设备""" - user_id = current_user['user_id'] + user_id = current_user["user_id"] revoked_count = jwt_service.revoke_all_user_tokens(user_id) - return {"message": f"Logged out from {revoked_count} devices"} + return create_api_response(code="200", message=f"从 {revoked_count} 个设备登出") + @router.post("/auth/admin/revoke-user-tokens/{user_id}") -def admin_revoke_user_tokens(user_id: int, credentials: HTTPAuthorizationCredentials = Depends(security)): +def admin_revoke_user_tokens( + user_id: int, credentials: HTTPAuthorizationCredentials = Depends(security) +): """管理员功能:撤销指定用户的所有token""" token = credentials.credentials - - # 验证管理员token(不查询数据库) + payload = jwt_service.verify_token(token) if not payload: - raise HTTPException(status_code=401, detail="Invalid or expired token") - + return create_api_response(code="401", message="无效或过期的token") + admin_user_id = payload.get("user_id") if not admin_user_id: - raise HTTPException(status_code=401, detail="Invalid token payload") - + return create_api_response(code="401", message="无效的token payload") + # 这里可以添加管理员权限检查,目前暂时允许任何登录用户操作 - # if not payload.get('is_admin'): - # raise HTTPException(status_code=403, detail="需要管理员权限") - + # if payload.get('role_id') != ADMIN_ROLE_ID: + # return create_api_response(code="403", message="需要管理员权限") + revoked_count = jwt_service.revoke_all_user_tokens(user_id) - return {"message": f"Revoked {revoked_count} tokens for user {user_id}"} + return create_api_response( + code="200", message=f"为用户 {user_id} 撤销了 {revoked_count} 个token" + ) + @router.get("/auth/me") def get_me(current_user: dict = Depends(get_current_user)): """获取当前用户信息""" - return current_user + return create_api_response(code="200", message="获取用户信息成功", data=current_user) + @router.post("/auth/refresh") def refresh_token(current_user: dict = Depends(get_current_user)): """刷新token""" - # 这里需要从请求中获取当前token,为简化先返回新token token_data = { - "user_id": current_user['user_id'], - "username": current_user['username'], - "caption": current_user['caption'], - "role_id": current_user['role_id'] + "user_id": current_user["user_id"], + "username": current_user["username"], + "caption": current_user["caption"], + "role_id": current_user["role_id"], } new_token = jwt_service.create_access_token(token_data) - return {"token": new_token} + return create_api_response( + code="200", message="Token刷新成功", data={"token": new_token} + ) diff --git a/app/api/endpoints/meetings.py b/app/api/endpoints/meetings.py index ad2d378..0206276 100644 --- a/app/api/endpoints/meetings.py +++ b/app/api/endpoints/meetings.py @@ -1,4 +1,3 @@ - from fastapi import APIRouter, HTTPException, UploadFile, File, Form, Depends, BackgroundTasks from fastapi.responses import StreamingResponse from app.models.models import Meeting, TranscriptSegment, TranscriptionTaskStatus, CreateMeetingRequest, UpdateMeetingRequest, SpeakerTagUpdateRequest, BatchSpeakerTagUpdateRequest, TranscriptUpdateRequest, BatchTranscriptUpdateRequest, Tag @@ -9,6 +8,7 @@ from app.services.llm_service import LLMService from app.services.async_transcription_service import AsyncTranscriptionService from app.services.async_llm_service import async_llm_service from app.core.auth import get_current_user +from app.core.response import create_api_response from typing import List, Optional from datetime import datetime from pydantic import BaseModel @@ -18,483 +18,254 @@ import shutil router = APIRouter() -# 实例化服务 llm_service = LLMService() transcription_service = AsyncTranscriptionService() -# 注意:异步LLM服务需要单独启动worker进程 -# 运行命令:python llm_worker.py - -# 请求模型 class GenerateSummaryRequest(BaseModel): user_prompt: Optional[str] = "" def _process_tags(cursor, tag_string: Optional[str]) -> List[Tag]: if not tag_string: return [] - tag_names = [name.strip() for name in tag_string.split(',') if name.strip()] if not tag_names: return [] - - # Ensure all tags exist in the 'tags' table insert_ignore_query = "INSERT IGNORE INTO tags (name) VALUES (%s)" cursor.executemany(insert_ignore_query, [(name,) for name in tag_names]) - - # Fetch the full tag objects format_strings = ', '.join(['%s'] * len(tag_names)) cursor.execute(f"SELECT id, name, color FROM tags WHERE name IN ({format_strings})", tuple(tag_names)) tags_data = cursor.fetchall() return [Tag(**tag) for tag in tags_data] -@router.get("/meetings", response_model=list[Meeting]) +@router.get("/meetings") def get_meetings(current_user: dict = Depends(get_current_user), user_id: Optional[int] = None): with get_db_connection() as connection: cursor = connection.cursor(dictionary=True) - base_query = ''' - SELECT - m.meeting_id, m.title, m.meeting_time, m.summary, m.created_at, m.tags, - m.user_id as creator_id, u.caption as creator_username - FROM meetings m - JOIN users u ON m.user_id = u.user_id + SELECT m.meeting_id, m.title, m.meeting_time, m.summary, m.created_at, m.tags, + m.user_id as creator_id, u.caption as creator_username + FROM meetings m JOIN users u ON m.user_id = u.user_id ''' - if user_id: - query = f''' - {base_query} - LEFT JOIN attendees a ON m.meeting_id = a.meeting_id - WHERE m.user_id = %s OR a.user_id = %s - GROUP BY m.meeting_id - ORDER BY m.meeting_time DESC, m.created_at DESC - ''' + query = f'{base_query} LEFT JOIN attendees a ON m.meeting_id = a.meeting_id WHERE m.user_id = %s OR a.user_id = %s GROUP BY m.meeting_id ORDER BY m.meeting_time DESC, m.created_at DESC' cursor.execute(query, (user_id, user_id)) else: query = f" {base_query} ORDER BY m.meeting_time DESC, m.created_at DESC" cursor.execute(query) - meetings = cursor.fetchall() - meeting_list = [] for meeting in meetings: - attendees_query = ''' - SELECT u.user_id, u.caption - FROM attendees a - JOIN users u ON a.user_id = u.user_id - WHERE a.meeting_id = %s - ''' + attendees_query = 'SELECT u.user_id, u.caption FROM attendees a JOIN users u ON a.user_id = u.user_id WHERE a.meeting_id = %s' cursor.execute(attendees_query, (meeting['meeting_id'],)) attendees_data = cursor.fetchall() attendees = [{'user_id': row['user_id'], 'caption': row['caption']} for row in attendees_data] - tags = _process_tags(cursor, meeting.get('tags')) - meeting_list.append(Meeting( - meeting_id=meeting['meeting_id'], - title=meeting['title'], - meeting_time=meeting['meeting_time'], - summary=meeting['summary'], - created_at=meeting['created_at'], - attendees=attendees, - creator_id=meeting['creator_id'], - creator_username=meeting['creator_username'], - tags=tags + meeting_id=meeting['meeting_id'], title=meeting['title'], meeting_time=meeting['meeting_time'], + summary=meeting['summary'], created_at=meeting['created_at'], attendees=attendees, + creator_id=meeting['creator_id'], creator_username=meeting['creator_username'], tags=tags )) - - return meeting_list + return create_api_response(code="200", message="获取会议列表成功", data=meeting_list) -@router.get("/meetings/{meeting_id}", response_model=Meeting) +@router.get("/meetings/{meeting_id}") def get_meeting_details(meeting_id: int, current_user: dict = Depends(get_current_user)): with get_db_connection() as connection: cursor = connection.cursor(dictionary=True) - query = ''' - SELECT - m.meeting_id, m.title, m.meeting_time, m.summary, m.created_at, m.tags, - m.user_id as creator_id, u.caption as creator_username, - af.file_path as audio_file_path - FROM meetings m - JOIN users u ON m.user_id = u.user_id - LEFT JOIN audio_files af ON m.meeting_id = af.meeting_id + SELECT m.meeting_id, m.title, m.meeting_time, m.summary, m.created_at, m.tags, + m.user_id as creator_id, u.caption as creator_username, af.file_path as audio_file_path + FROM meetings m JOIN users u ON m.user_id = u.user_id LEFT JOIN audio_files af ON m.meeting_id = af.meeting_id WHERE m.meeting_id = %s ''' cursor.execute(query, (meeting_id,)) meeting = cursor.fetchone() - if not meeting: - cursor.close() # 明确关闭游标 - raise HTTPException(status_code=404, detail="Meeting not found") - - attendees_query = ''' - SELECT u.user_id, u.caption - FROM attendees a - JOIN users u ON a.user_id = u.user_id - WHERE a.meeting_id = %s - ''' + return create_api_response(code="404", message="Meeting not found") + attendees_query = 'SELECT u.user_id, u.caption FROM attendees a JOIN users u ON a.user_id = u.user_id WHERE a.meeting_id = %s' cursor.execute(attendees_query, (meeting['meeting_id'],)) attendees_data = cursor.fetchall() attendees = [{'user_id': row['user_id'], 'caption': row['caption']} for row in attendees_data] - tags = _process_tags(cursor, meeting.get('tags')) - - # 关闭游标,避免与转录服务的数据库连接冲突 cursor.close() - meeting_data = Meeting( - meeting_id=meeting['meeting_id'], - title=meeting['title'], - meeting_time=meeting['meeting_time'], - summary=meeting['summary'], - created_at=meeting['created_at'], - attendees=attendees, - creator_id=meeting['creator_id'], - creator_username=meeting['creator_username'], - tags=tags + meeting_id=meeting['meeting_id'], title=meeting['title'], meeting_time=meeting['meeting_time'], + summary=meeting['summary'], created_at=meeting['created_at'], attendees=attendees, + creator_id=meeting['creator_id'], creator_username=meeting['creator_username'], tags=tags ) - - # Add audio file path if exists if meeting['audio_file_path']: meeting_data.audio_file_path = meeting['audio_file_path'] - - # 在连接外部获取转录状态,避免游标冲突 try: transcription_status_data = transcription_service.get_meeting_transcription_status(meeting_id) if transcription_status_data: meeting_data.transcription_status = TranscriptionTaskStatus(**transcription_status_data) except Exception as e: print(f"Warning: Failed to get transcription status for meeting {meeting_id}: {e}") - # Don't fail the entire request if transcription status can't be fetched - - return meeting_data + return create_api_response(code="200", message="获取会议详情成功", data=meeting_data) -@router.get("/meetings/{meeting_id}/transcript", response_model=list[TranscriptSegment]) +@router.get("/meetings/{meeting_id}/transcript") def get_meeting_transcript(meeting_id: int, current_user: dict = Depends(get_current_user)): - """获取会议的转录内容""" with get_db_connection() as connection: cursor = connection.cursor(dictionary=True) - - # First check if meeting exists - meeting_query = "SELECT meeting_id FROM meetings WHERE meeting_id = %s" - cursor.execute(meeting_query, (meeting_id,)) + cursor.execute("SELECT meeting_id FROM meetings WHERE meeting_id = %s", (meeting_id,)) if not cursor.fetchone(): - raise HTTPException(status_code=404, detail="Meeting not found") - - # Get transcript segments + return create_api_response(code="404", message="Meeting not found") transcript_query = ''' SELECT segment_id, meeting_id, speaker_id, speaker_tag, start_time_ms, end_time_ms, text_content - FROM transcript_segments - WHERE meeting_id = %s - ORDER BY start_time_ms ASC + FROM transcript_segments WHERE meeting_id = %s ORDER BY start_time_ms ASC ''' cursor.execute(transcript_query, (meeting_id,)) segments = cursor.fetchall() - - transcript_segments = [] - for segment in segments: - transcript_segments.append(TranscriptSegment( - segment_id=segment['segment_id'], - meeting_id=segment['meeting_id'], - speaker_id=segment['speaker_id'], - speaker_tag=segment['speaker_tag'] if segment['speaker_tag'] else f"发言人 {segment['speaker_id']}", - start_time_ms=segment['start_time_ms'], - end_time_ms=segment['end_time_ms'], - text_content=segment['text_content'] - )) - - return transcript_segments + transcript_segments = [TranscriptSegment( + segment_id=s['segment_id'], meeting_id=s['meeting_id'], speaker_id=s['speaker_id'], + speaker_tag=s['speaker_tag'] if s['speaker_tag'] else f"发言人 {s['speaker_id']}", + start_time_ms=s['start_time_ms'], end_time_ms=s['end_time_ms'], text_content=s['text_content'] + ) for s in segments] + return create_api_response(code="200", message="获取转录内容成功", data=transcript_segments) @router.post("/meetings") def create_meeting(meeting_request: CreateMeetingRequest, current_user: dict = Depends(get_current_user)): with get_db_connection() as connection: cursor = connection.cursor(dictionary=True) - - # Process tags if meeting_request.tags: tag_names = [name.strip() for name in meeting_request.tags.split(',') if name.strip()] if tag_names: - insert_ignore_query = "INSERT IGNORE INTO tags (name) VALUES (%s)" - cursor.executemany(insert_ignore_query, [(name,) for name in tag_names]) - - # Create meeting - meeting_query = ''' - INSERT INTO meetings (user_id, title, meeting_time, summary, tags, created_at) - VALUES (%s, %s, %s, %s, %s, %s) - ''' - cursor.execute(meeting_query, ( - meeting_request.user_id, - meeting_request.title, - meeting_request.meeting_time, - None, - meeting_request.tags, - datetime.now().isoformat() - )) - + cursor.executemany("INSERT IGNORE INTO tags (name) VALUES (%s)", [(name,) for name in tag_names]) + meeting_query = 'INSERT INTO meetings (user_id, title, meeting_time, summary, tags, created_at) VALUES (%s, %s, %s, %s, %s, %s)' + cursor.execute(meeting_query, (meeting_request.user_id, meeting_request.title, meeting_request.meeting_time, None, meeting_request.tags, datetime.now().isoformat())) meeting_id = cursor.lastrowid - - # Add attendees for attendee_id in meeting_request.attendee_ids: - attendee_query = ''' - INSERT IGNORE INTO attendees (meeting_id, user_id) - VALUES (%s, %s) - ''' - cursor.execute(attendee_query, (meeting_id, attendee_id)) - + cursor.execute('INSERT IGNORE INTO attendees (meeting_id, user_id) VALUES (%s, %s)', (meeting_id, attendee_id)) connection.commit() - return {"message": "Meeting created successfully", "meeting_id": meeting_id} + return create_api_response(code="200", message="Meeting created successfully", data={"meeting_id": meeting_id}) @router.put("/meetings/{meeting_id}") def update_meeting(meeting_id: int, meeting_request: UpdateMeetingRequest, current_user: dict = Depends(get_current_user)): with get_db_connection() as connection: cursor = connection.cursor(dictionary=True) - - # Check if meeting exists and user has permission cursor.execute("SELECT user_id FROM meetings WHERE meeting_id = %s", (meeting_id,)) meeting = cursor.fetchone() if not meeting: - raise HTTPException(status_code=404, detail="Meeting not found") - + return create_api_response(code="404", message="Meeting not found") if meeting['user_id'] != current_user['user_id']: - raise HTTPException(status_code=403, detail="Permission denied") - - # Process tags + return create_api_response(code="403", message="Permission denied") if meeting_request.tags: tag_names = [name.strip() for name in meeting_request.tags.split(',') if name.strip()] if tag_names: - insert_ignore_query = "INSERT IGNORE INTO tags (name) VALUES (%s)" - cursor.executemany(insert_ignore_query, [(name,) for name in tag_names]) - - # Update meeting - update_query = ''' - UPDATE meetings - SET title = %s, meeting_time = %s, summary = %s, tags = %s - WHERE meeting_id = %s - ''' - cursor.execute(update_query, ( - meeting_request.title, - meeting_request.meeting_time, - meeting_request.summary, - meeting_request.tags, - meeting_id - )) - - # Update attendees - remove existing and add new ones + cursor.executemany("INSERT IGNORE INTO tags (name) VALUES (%s)", [(name,) for name in tag_names]) + update_query = 'UPDATE meetings SET title = %s, meeting_time = %s, summary = %s, tags = %s WHERE meeting_id = %s' + cursor.execute(update_query, (meeting_request.title, meeting_request.meeting_time, meeting_request.summary, meeting_request.tags, meeting_id)) cursor.execute("DELETE FROM attendees WHERE meeting_id = %s", (meeting_id,)) - for attendee_id in meeting_request.attendee_ids: - attendee_query = ''' - INSERT INTO attendees (meeting_id, user_id) - VALUES (%s, %s) - ''' - cursor.execute(attendee_query, (meeting_id, attendee_id)) - + cursor.execute('INSERT INTO attendees (meeting_id, user_id) VALUES (%s, %s)', (meeting_id, attendee_id)) connection.commit() - return {"message": "Meeting updated successfully"} + return create_api_response(code="200", message="Meeting updated successfully") @router.delete("/meetings/{meeting_id}") def delete_meeting(meeting_id: int, current_user: dict = Depends(get_current_user)): with get_db_connection() as connection: cursor = connection.cursor(dictionary=True) - - # Check if meeting exists and user has permission cursor.execute("SELECT user_id FROM meetings WHERE meeting_id = %s", (meeting_id,)) meeting = cursor.fetchone() if not meeting: - raise HTTPException(status_code=404, detail="Meeting not found") - + return create_api_response(code="404", message="Meeting not found") if meeting['user_id'] != current_user['user_id']: - raise HTTPException(status_code=403, detail="Permission denied") - - # Delete related records first (foreign key constraints) + return create_api_response(code="403", message="Permission denied") cursor.execute("DELETE FROM transcript_segments WHERE meeting_id = %s", (meeting_id,)) cursor.execute("DELETE FROM audio_files WHERE meeting_id = %s", (meeting_id,)) cursor.execute("DELETE FROM attachments WHERE meeting_id = %s", (meeting_id,)) cursor.execute("DELETE FROM attendees WHERE meeting_id = %s", (meeting_id,)) - - # Delete meeting cursor.execute("DELETE FROM meetings WHERE meeting_id = %s", (meeting_id,)) - connection.commit() - return {"message": "Meeting deleted successfully"} + return create_api_response(code="200", message="Meeting deleted successfully") -@router.get("/meetings/{meeting_id}/edit", response_model=Meeting) +@router.get("/meetings/{meeting_id}/edit") def get_meeting_for_edit(meeting_id: int, current_user: dict = Depends(get_current_user)): - """获取会议信息用于编辑""" with get_db_connection() as connection: cursor = connection.cursor(dictionary=True) - query = ''' - SELECT - m.meeting_id, m.title, m.meeting_time, m.summary, m.created_at, m.tags, - m.user_id as creator_id, u.caption as creator_username, - af.file_path as audio_file_path - FROM meetings m - JOIN users u ON m.user_id = u.user_id - LEFT JOIN audio_files af ON m.meeting_id = af.meeting_id + SELECT m.meeting_id, m.title, m.meeting_time, m.summary, m.created_at, m.tags, + m.user_id as creator_id, u.caption as creator_username, af.file_path as audio_file_path + FROM meetings m JOIN users u ON m.user_id = u.user_id LEFT JOIN audio_files af ON m.meeting_id = af.meeting_id WHERE m.meeting_id = %s ''' cursor.execute(query, (meeting_id,)) meeting = cursor.fetchone() - if not meeting: - cursor.close() - raise HTTPException(status_code=404, detail="Meeting not found") - - # Get attendees - attendees_query = ''' - SELECT u.user_id, u.caption - FROM attendees a - JOIN users u ON a.user_id = u.user_id - WHERE a.meeting_id = %s - ''' - cursor.execute(attendees_query, (meeting_id,)) + return create_api_response(code="404", message="Meeting not found") + attendees_query = 'SELECT u.user_id, u.caption FROM attendees a JOIN users u ON a.user_id = u.user_id WHERE a.meeting_id = %s' + cursor.execute(attendees_query, (meeting['meeting_id'],)) attendees_data = cursor.fetchall() attendees = [{'user_id': row['user_id'], 'caption': row['caption']} for row in attendees_data] - tags = _process_tags(cursor, meeting.get('tags')) - - # 关闭游标,避免与转录服务的数据库连接冲突 cursor.close() - meeting_data = Meeting( - meeting_id=meeting['meeting_id'], - title=meeting['title'], - meeting_time=meeting['meeting_time'], - summary=meeting['summary'], - created_at=meeting['created_at'], - attendees=attendees, - creator_id=meeting['creator_id'], - creator_username=meeting['creator_username'], - tags=tags + meeting_id=meeting['meeting_id'], title=meeting['title'], meeting_time=meeting['meeting_time'], + summary=meeting['summary'], created_at=meeting['created_at'], attendees=attendees, + creator_id=meeting['creator_id'], creator_username=meeting['creator_username'], tags=tags ) - - # Add audio file path if exists if meeting.get('audio_file_path'): meeting_data.audio_file_path = meeting['audio_file_path'] - - # 在连接外部获取转录状态,避免游标冲突 try: transcription_status_data = transcription_service.get_meeting_transcription_status(meeting_id) if transcription_status_data: meeting_data.transcription_status = TranscriptionTaskStatus(**transcription_status_data) except Exception as e: print(f"Warning: Failed to get transcription status for meeting {meeting_id}: {e}") - - return meeting_data + return create_api_response(code="200", message="获取会议编辑信息成功", data=meeting_data) @router.post("/meetings/upload-audio") -async def upload_audio( - audio_file: UploadFile = File(...), - meeting_id: int = Form(...), - force_replace: str = Form("false"), # 接收字符串,然后手动转换 - current_user: dict = Depends(get_current_user) -): - # Convert string to boolean +async def upload_audio(audio_file: UploadFile = File(...), meeting_id: int = Form(...), force_replace: str = Form("false"), current_user: dict = Depends(get_current_user)): force_replace_bool = force_replace.lower() in ("true", "1", "yes") - - # Validate file extension file_extension = os.path.splitext(audio_file.filename)[1].lower() if file_extension not in ALLOWED_EXTENSIONS: - raise HTTPException( - status_code=400, - detail=f"Unsupported file type. Allowed types: {', '.join(ALLOWED_EXTENSIONS)}" - ) - - # Check file size using dynamic config + return create_api_response(code="400", message=f"Unsupported file type. Allowed types: {', '.join(ALLOWED_EXTENSIONS)}") max_file_size = getattr(config_module, 'MAX_FILE_SIZE', 100 * 1024 * 1024) if audio_file.size > max_file_size: - max_size_mb = max_file_size // (1024 * 1024) - raise HTTPException( - status_code=400, - detail=f"File size exceeds {max_size_mb}MB limit" - ) - - # 检查是否已有音频文件和转录记录 - existing_info = None - has_transcription = False - + return create_api_response(code="400", message=f"File size exceeds {max_file_size // (1024 * 1024)}MB limit") try: with get_db_connection() as connection: cursor = connection.cursor(dictionary=True) - - # Check if meeting exists and user has permission cursor.execute("SELECT user_id FROM meetings WHERE meeting_id = %s", (meeting_id,)) meeting = cursor.fetchone() if not meeting: - cursor.close() - raise HTTPException(status_code=404, detail="Meeting not found") - + return create_api_response(code="404", message="Meeting not found") if meeting['user_id'] != current_user['user_id']: - cursor.close() - raise HTTPException(status_code=403, detail="Permission denied") - - # Check existing audio file - cursor.execute(""" - SELECT file_name, file_path, upload_time - FROM audio_files - WHERE meeting_id = %s - """, (meeting_id,)) + return create_api_response(code="403", message="Permission denied") + cursor.execute("SELECT file_name, file_path, upload_time FROM audio_files WHERE meeting_id = %s", (meeting_id,)) existing_info = cursor.fetchone() - - # Check existing transcription segments + has_transcription = False if existing_info: - cursor.execute(""" - SELECT COUNT(*) as segment_count - FROM transcript_segments - WHERE meeting_id = %s - """, (meeting_id,)) - result = cursor.fetchone() - has_transcription = result['segment_count'] > 0 - + cursor.execute("SELECT COUNT(*) as segment_count FROM transcript_segments WHERE meeting_id = %s", (meeting_id,)) + has_transcription = cursor.fetchone()['segment_count'] > 0 cursor.close() except Exception as e: - raise HTTPException(status_code=500, detail=f"Failed to check existing files: {str(e)}") - - # 如果存在音频文件且有转录记录,需要用户确认 + return create_api_response(code="500", message=f"Failed to check existing files: {str(e)}") if existing_info and has_transcription and not force_replace_bool: - return { + return create_api_response(code="300", message="该会议已有音频文件和转录记录,重新上传将删除现有的转录内容", data={ "requires_confirmation": True, - "message": "该会议已有音频文件和转录记录,重新上传将删除现有的转录内容", "existing_file": { "file_name": existing_info['file_name'], "upload_time": existing_info['upload_time'].isoformat() if existing_info['upload_time'] else None - }, - "transcription_segments": has_transcription, - "suggestion": "请确认是否要替换现有音频文件并重新进行转录" - } - - # Create meeting-specific directory + } + }) meeting_dir = AUDIO_DIR / str(meeting_id) meeting_dir.mkdir(exist_ok=True) - - # Generate unique filename unique_filename = f"{uuid.uuid4()}{file_extension}" absolute_path = meeting_dir / unique_filename relative_path = absolute_path.relative_to(BASE_DIR) - - # Save file try: with open(absolute_path, "wb") as buffer: shutil.copyfileobj(audio_file.file, buffer) except Exception as e: - raise HTTPException(status_code=500, detail=f"Failed to save file: {str(e)}") - - # Save file info to database and start transcription + return create_api_response(code="500", message=f"Failed to save file: {str(e)}") task_id = None replaced_existing = existing_info is not None - try: with get_db_connection() as connection: cursor = connection.cursor(dictionary=True) - - # 如果是替换操作,清理旧的转录数据 if replaced_existing and force_replace_bool: - # Delete existing transcription segments cursor.execute("DELETE FROM transcript_segments WHERE meeting_id = %s", (meeting_id,)) - - # Delete existing transcription tasks cursor.execute("DELETE FROM transcript_tasks WHERE meeting_id = %s", (meeting_id,)) - - # Clean up old audio file if existing_info and existing_info['file_path']: old_file_path = BASE_DIR / existing_info['file_path'].lstrip('/') if old_file_path.exists(): @@ -502,312 +273,177 @@ async def upload_audio( os.remove(old_file_path) except Exception as e: print(f"Warning: Failed to delete old file {old_file_path}: {e}") - - # Insert or update audio file record if replaced_existing: - update_query = ''' - UPDATE audio_files - SET file_name = %s, file_path = %s, file_size = %s, upload_time = NOW(), task_id = NULL - WHERE meeting_id = %s - ''' - cursor.execute(update_query, (audio_file.filename, '/'+str(relative_path), audio_file.size, meeting_id)) + cursor.execute('UPDATE audio_files SET file_name = %s, file_path = %s, file_size = %s, upload_time = NOW(), task_id = NULL WHERE meeting_id = %s', (audio_file.filename, '/'+str(relative_path), audio_file.size, meeting_id)) else: - insert_query = ''' - INSERT INTO audio_files (meeting_id, file_name, file_path, file_size, upload_time) - VALUES (%s, %s, %s, %s, NOW()) - ''' - cursor.execute(insert_query, (meeting_id, audio_file.filename, '/'+str(relative_path), audio_file.size)) - + cursor.execute('INSERT INTO audio_files (meeting_id, file_name, file_path, file_size, upload_time) VALUES (%s, %s, %s, %s, NOW())', (meeting_id, audio_file.filename, '/'+str(relative_path), audio_file.size)) connection.commit() cursor.close() - - # Start transcription task try: task_id = transcription_service.start_transcription(meeting_id, '/'+str(relative_path)) print(f"Transcription task started with ID: {task_id}") except Exception as e: print(f"Failed to start transcription task: {e}") - # 不抛出异常,允许文件上传成功但转录失败 - except Exception as e: - # Clean up uploaded file on database error if os.path.exists(absolute_path): os.remove(absolute_path) - raise HTTPException(status_code=500, detail=f"Failed to save file info: {str(e)}") - - return { - "message": "Audio file uploaded successfully" + (" and replaced existing file" if replaced_existing else ""), - "file_name": audio_file.filename, - "file_path": '/'+str(relative_path), - "task_id": task_id, - "transcription_started": task_id is not None, - "replaced_existing": replaced_existing, + return create_api_response(code="500", message=f"Failed to save file info: {str(e)}") + return create_api_response(code="200", message="Audio file uploaded successfully" + (" and replaced existing file" if replaced_existing else ""), data={ + "file_name": audio_file.filename, "file_path": '/'+str(relative_path), "task_id": task_id, + "transcription_started": task_id is not None, "replaced_existing": replaced_existing, "previous_transcription_cleared": replaced_existing and has_transcription - } + }) @router.get("/meetings/{meeting_id}/audio") def get_audio_file(meeting_id: int, current_user: dict = Depends(get_current_user)): with get_db_connection() as connection: cursor = connection.cursor(dictionary=True) - - query = ''' - SELECT file_name, file_path, file_size, upload_time - FROM audio_files - WHERE meeting_id = %s - ''' - cursor.execute(query, (meeting_id,)) + cursor.execute("SELECT file_name, file_path, file_size, upload_time FROM audio_files WHERE meeting_id = %s", (meeting_id,)) audio_file = cursor.fetchone() - if not audio_file: - raise HTTPException(status_code=404, detail="Audio file not found for this meeting") - - return { - "file_name": audio_file['file_name'], - "file_path": audio_file['file_path'], - "file_size": audio_file['file_size'], - "upload_time": audio_file['upload_time'] - } + return create_api_response(code="404", message="Audio file not found for this meeting") + return create_api_response(code="200", message="Audio file found", data=audio_file) -# 转录任务相关接口 @router.get("/transcription/tasks/{task_id}/status") def get_transcription_task_status(task_id: str, current_user: dict = Depends(get_current_user)): - """获取转录任务状态""" try: status_info = transcription_service.get_task_status(task_id) - return status_info + return create_api_response(code="200", message="Task status retrieved", data=status_info) except Exception as e: if "Task not found" in str(e): - raise HTTPException(status_code=404, detail="Transcription task not found") + return create_api_response(code="404", message="Transcription task not found") else: - raise HTTPException(status_code=500, detail=f"Failed to get task status: {str(e)}") + return create_api_response(code="500", message=f"Failed to get task status: {str(e)}") @router.get("/meetings/{meeting_id}/transcription/status") def get_meeting_transcription_status(meeting_id: int, current_user: dict = Depends(get_current_user)): - """获取会议的转录任务状态""" try: status_info = transcription_service.get_meeting_transcription_status(meeting_id) if not status_info: - return {"message": "No transcription task found for this meeting"} - return status_info + return create_api_response(code="404", message="No transcription task found for this meeting") + return create_api_response(code="200", message="Transcription status retrieved", data=status_info) except Exception as e: - raise HTTPException(status_code=500, detail=f"Failed to get meeting transcription status: {str(e)}") + return create_api_response(code="500", message=f"Failed to get meeting transcription status: {str(e)}") @router.post("/meetings/{meeting_id}/transcription/start") def start_meeting_transcription(meeting_id: int, current_user: dict = Depends(get_current_user)): - """手动启动会议转录任务(如果有音频文件的话)""" try: with get_db_connection() as connection: cursor = connection.cursor(dictionary=True) - - # 检查会议是否存在 cursor.execute("SELECT meeting_id FROM meetings WHERE meeting_id = %s", (meeting_id,)) if not cursor.fetchone(): - raise HTTPException(status_code=404, detail="Meeting not found") - - # 查询音频文件 - audio_query = "SELECT file_path FROM audio_files WHERE meeting_id = %s LIMIT 1" - cursor.execute(audio_query, (meeting_id,)) + return create_api_response(code="404", message="Meeting not found") + cursor.execute("SELECT file_path FROM audio_files WHERE meeting_id = %s LIMIT 1", (meeting_id,)) audio_file = cursor.fetchone() - if not audio_file: - raise HTTPException(status_code=400, detail="No audio file found for this meeting") - - # 检查是否已有进行中的任务 + return create_api_response(code="400", message="No audio file found for this meeting") existing_status = transcription_service.get_meeting_transcription_status(meeting_id) if existing_status and existing_status['status'] in ['pending', 'processing']: - return { - "message": "Transcription task already exists", - "task_id": existing_status['task_id'], - "status": existing_status['status'] - } - - # 启动转录任务 + return create_api_response(code="409", message="Transcription task already exists", data={ + "task_id": existing_status['task_id'], "status": existing_status['status'] + }) task_id = transcription_service.start_transcription(meeting_id, audio_file['file_path']) - - return { - "message": "Transcription task started successfully", - "task_id": task_id, - "meeting_id": meeting_id - } - - except HTTPException: - raise + return create_api_response(code="200", message="Transcription task started successfully", data={ + "task_id": task_id, "meeting_id": meeting_id + }) except Exception as e: - raise HTTPException(status_code=500, detail=f"Failed to start transcription: {str(e)}") + return create_api_response(code="500", message=f"Failed to start transcription: {str(e)}") @router.post("/meetings/{meeting_id}/upload-image") -async def upload_image( - meeting_id: int, - image_file: UploadFile = File(...), - current_user: dict = Depends(get_current_user) -): - # Validate file extension +async def upload_image(meeting_id: int, image_file: UploadFile = File(...), current_user: dict = Depends(get_current_user)): file_extension = os.path.splitext(image_file.filename)[1].lower() if file_extension not in ALLOWED_IMAGE_EXTENSIONS: - raise HTTPException( - status_code=400, - detail=f"Unsupported image type. Allowed types: {', '.join(ALLOWED_IMAGE_EXTENSIONS)}" - ) - - # Check file size using dynamic config + return create_api_response(code="400", message=f"Unsupported image type. Allowed types: {', '.join(ALLOWED_IMAGE_EXTENSIONS)}") max_image_size = getattr(config_module, 'MAX_IMAGE_SIZE', 10 * 1024 * 1024) if image_file.size > max_image_size: - max_size_mb = max_image_size // (1024 * 1024) - raise HTTPException( - status_code=400, - detail=f"Image size exceeds {max_size_mb}MB limit" - ) - - # Check if meeting exists and user has permission + return create_api_response(code="400", message=f"Image size exceeds {max_image_size // (1024 * 1024)}MB limit") with get_db_connection() as connection: cursor = connection.cursor(dictionary=True) cursor.execute("SELECT user_id FROM meetings WHERE meeting_id = %s", (meeting_id,)) meeting = cursor.fetchone() if not meeting: - raise HTTPException(status_code=404, detail="Meeting not found") - + return create_api_response(code="404", message="Meeting not found") if meeting['user_id'] != current_user['user_id']: - raise HTTPException(status_code=403, detail="Permission denied") - - # Create meeting-specific directory + return create_api_response(code="403", message="Permission denied") meeting_dir = MARKDOWN_DIR / str(meeting_id) meeting_dir.mkdir(exist_ok=True) - - # Generate unique filename unique_filename = f"{uuid.uuid4()}{file_extension}" absolute_path = meeting_dir / unique_filename relative_path = absolute_path.relative_to(BASE_DIR) - - # Save file try: with open(absolute_path, "wb") as buffer: shutil.copyfileobj(image_file.file, buffer) except Exception as e: - raise HTTPException(status_code=500, detail=f"Failed to save image: {str(e)}") - - return { - "message": "Image uploaded successfully", - "file_name": image_file.filename, - "file_path": '/'+ str(relative_path) - } + return create_api_response(code="500", message=f"Failed to save image: {str(e)}") + return create_api_response(code="200", message="Image uploaded successfully", data= { + "file_name": image_file.filename, "file_path": '/'+ str(relative_path) + }) -# 发言人标签更新接口 @router.put("/meetings/{meeting_id}/speaker-tags") def update_speaker_tag(meeting_id: int, request: SpeakerTagUpdateRequest, current_user: dict = Depends(get_current_user)): - """更新单个发言人标签(基于原始的speaker_id值)""" try: with get_db_connection() as connection: cursor = connection.cursor() - - # 只修改speaker_tag,保留speaker_id的原始值 - update_query = """ - UPDATE transcript_segments - SET speaker_tag = %s - WHERE meeting_id = %s AND speaker_id = %s - """ + update_query = 'UPDATE transcript_segments SET speaker_tag = %s WHERE meeting_id = %s AND speaker_id = %s' cursor.execute(update_query, (request.new_tag, meeting_id, request.speaker_id)) - if cursor.rowcount == 0: - raise HTTPException(status_code=404, detail="No segments found for this speaker") - + return create_api_response(code="404", message="No segments found for this speaker") connection.commit() - return {'message': 'Speaker tag updated successfully', 'updated_count': cursor.rowcount} - + return create_api_response(code="200", message="Speaker tag updated successfully", data={'updated_count': cursor.rowcount}) except Exception as e: - raise HTTPException(status_code=500, detail=f"Failed to update speaker tag: {str(e)}") + return create_api_response(code="500", message=f"Failed to update speaker tag: {str(e)}") @router.put("/meetings/{meeting_id}/speaker-tags/batch") def batch_update_speaker_tags(meeting_id: int, request: BatchSpeakerTagUpdateRequest, current_user: dict = Depends(get_current_user)): - """批量更新发言人标签(基于原始的speaker_id值)""" try: with get_db_connection() as connection: cursor = connection.cursor() - total_updated = 0 for update_item in request.updates: - # 只修改speaker_tag,保留speaker_id的原始值 - update_query = """ - UPDATE transcript_segments - SET speaker_tag = %s - WHERE meeting_id = %s AND speaker_id = %s - """ + update_query = 'UPDATE transcript_segments SET speaker_tag = %s WHERE meeting_id = %s AND speaker_id = %s' cursor.execute(update_query, (update_item.new_tag, meeting_id, update_item.speaker_id)) total_updated += cursor.rowcount - connection.commit() - return {'message': 'Speaker tags updated successfully', 'total_updated': total_updated} - + return create_api_response(code="200", message="Speaker tags updated successfully", data={'total_updated': total_updated}) except Exception as e: - raise HTTPException(status_code=500, detail=f"Failed to batch update speaker tags: {str(e)}") + return create_api_response(code="500", message=f"Failed to batch update speaker tags: {str(e)}") -# 转录内容更新接口 @router.put("/meetings/{meeting_id}/transcript/batch") def batch_update_transcript(meeting_id: int, request: BatchTranscriptUpdateRequest, current_user: dict = Depends(get_current_user)): - """批量更新转录内容""" try: with get_db_connection() as connection: cursor = connection.cursor() - total_updated = 0 for update_item in request.updates: - # 验证segment_id是否属于指定会议 - verify_query = "SELECT segment_id FROM transcript_segments WHERE segment_id = %s AND meeting_id = %s" - cursor.execute(verify_query, (update_item.segment_id, meeting_id)) + cursor.execute("SELECT segment_id FROM transcript_segments WHERE segment_id = %s AND meeting_id = %s", (update_item.segment_id, meeting_id)) if not cursor.fetchone(): - continue # 跳过不属于该会议的转录条目 - - # 更新转录内容 - update_query = """ - UPDATE transcript_segments - SET text_content = %s - WHERE segment_id = %s AND meeting_id = %s - """ + continue + update_query = 'UPDATE transcript_segments SET text_content = %s WHERE segment_id = %s AND meeting_id = %s' cursor.execute(update_query, (update_item.text_content, update_item.segment_id, meeting_id)) total_updated += cursor.rowcount - connection.commit() - return {'message': 'Transcript updated successfully', 'total_updated': total_updated} - + return create_api_response(code="200", message="Transcript updated successfully", data={'total_updated': total_updated}) except Exception as e: - raise HTTPException(status_code=500, detail=f"Failed to update transcript: {str(e)}") + return create_api_response(code="500", message=f"Failed to update transcript: {str(e)}") -# AI总结相关接口 @router.post("/meetings/{meeting_id}/generate-summary-stream") def generate_meeting_summary_stream(meeting_id: int, request: GenerateSummaryRequest, current_user: dict = Depends(get_current_user)): - """生成会议AI总结(流式输出)""" try: - # 检查会议是否存在 with get_db_connection() as connection: cursor = connection.cursor(dictionary=True) cursor.execute("SELECT meeting_id FROM meetings WHERE meeting_id = %s", (meeting_id,)) if not cursor.fetchone(): raise HTTPException(status_code=404, detail="Meeting not found") - - # 创建流式生成器 def generate_stream(): for chunk in llm_service.generate_meeting_summary_stream(meeting_id, request.user_prompt): if chunk.startswith("error:"): - # 如果遇到错误,发送错误信息并结束 - yield f"data: {{\"error\": \"{chunk[6:]}\"}}\n\n" + yield f"data: {{'error': '{chunk[6:]}'}}\n\n" break else: - # 发送正常的内容块 import json - yield f"data: {{\"content\": {json.dumps(chunk, ensure_ascii=False)}}}\n\n" - - # 发送结束标记 - yield "data: {\"done\": true}\n\n" - - return StreamingResponse( - generate_stream(), - media_type="text/plain", - headers={ - "Cache-Control": "no-cache", - "Connection": "keep-alive", - "Content-Type": "text/plain; charset=utf-8" - } - ) - + yield f"data: {{'content': {json.dumps(chunk, ensure_ascii=False)}}}\\n\n" + yield "data: {\'done\': true}\\n\n" + return StreamingResponse(generate_stream(), media_type="text/plain", headers={"Cache-Control": "no-cache", "Connection": "keep-alive", "Content-Type": "text/plain; charset=utf-8"}) except HTTPException: raise except Exception as e: @@ -815,185 +451,83 @@ def generate_meeting_summary_stream(meeting_id: int, request: GenerateSummaryReq @router.post("/meetings/{meeting_id}/generate-summary") def generate_meeting_summary(meeting_id: int, request: GenerateSummaryRequest, current_user: dict = Depends(get_current_user)): - """生成会议AI总结""" try: - # 检查会议是否存在 with get_db_connection() as connection: cursor = connection.cursor(dictionary=True) cursor.execute("SELECT meeting_id FROM meetings WHERE meeting_id = %s", (meeting_id,)) if not cursor.fetchone(): - raise HTTPException(status_code=404, detail="Meeting not found") - - # 调用LLM服务生成总结 + return create_api_response(code="404", message="Meeting not found") result = llm_service.generate_meeting_summary(meeting_id, request.user_prompt) - if result.get("error"): - raise HTTPException(status_code=500, detail=result["error"]) - - return { - "message": "Summary generated successfully", - "summary_id": result["summary_id"], - "content": result["content"], - "meeting_id": meeting_id - } - - except HTTPException: - raise + return create_api_response(code="500", message=result["error"]) + return create_api_response(code="200", message="Summary generated successfully", data=result) except Exception as e: - raise HTTPException(status_code=500, detail=f"Failed to generate summary: {str(e)}") + return create_api_response(code="500", message=f"Failed to generate summary: {str(e)}") @router.get("/meetings/{meeting_id}/summaries") def get_meeting_summaries(meeting_id: int, current_user: dict = Depends(get_current_user)): - """获取会议的所有AI总结历史""" try: - # 检查会议是否存在 with get_db_connection() as connection: cursor = connection.cursor(dictionary=True) cursor.execute("SELECT meeting_id FROM meetings WHERE meeting_id = %s", (meeting_id,)) if not cursor.fetchone(): - raise HTTPException(status_code=404, detail="Meeting not found") - - # 获取总结列表 + return create_api_response(code="404", message="Meeting not found") summaries = llm_service.get_meeting_summaries(meeting_id) - - return { - "meeting_id": meeting_id, - "summaries": summaries - } - - except HTTPException: - raise + return create_api_response(code="200", message="Summaries retrieved successfully", data={"summaries": summaries}) except Exception as e: - raise HTTPException(status_code=500, detail=f"Failed to get summaries: {str(e)}") + return create_api_response(code="500", message=f"Failed to get summaries: {str(e)}") @router.get("/meetings/{meeting_id}/summaries/{summary_id}") def get_summary_detail(meeting_id: int, summary_id: int, current_user: dict = Depends(get_current_user)): - """获取特定总结的详细内容""" try: with get_db_connection() as connection: cursor = connection.cursor(dictionary=True) - query = """ - SELECT id, summary_content, user_prompt, created_at - FROM meeting_summaries - WHERE id = %s AND meeting_id = %s - """ + query = "SELECT id, summary_content, user_prompt, created_at FROM meeting_summaries WHERE id = %s AND meeting_id = %s" cursor.execute(query, (summary_id, meeting_id)) summary = cursor.fetchone() - if not summary: - raise HTTPException(status_code=404, detail="Summary not found") - - return { - "id": summary["id"], - "meeting_id": meeting_id, - "content": summary["summary_content"], - "user_prompt": summary["user_prompt"], - "created_at": summary["created_at"].isoformat() if summary["created_at"] else None - } - - except HTTPException: - raise + return create_api_response(code="404", message="Summary not found") + return create_api_response(code="200", message="Summary detail retrieved", data=summary) except Exception as e: - raise HTTPException(status_code=500, detail=f"Failed to get summary detail: {str(e)}") - -# ==================== 异步LLM总结相关接口 ==================== + return create_api_response(code="500", message=f"Failed to get summary detail: {str(e)}") @router.post("/meetings/{meeting_id}/generate-summary-async") def generate_meeting_summary_async(meeting_id: int, request: GenerateSummaryRequest, background_tasks: BackgroundTasks, current_user: dict = Depends(get_current_user)): - """生成会议AI总结(异步版本)""" try: - - # 1. 检查会议是否存在 with get_db_connection() as connection: cursor = connection.cursor(dictionary=True) cursor.execute("SELECT meeting_id FROM meetings WHERE meeting_id = %s", (meeting_id,)) if not cursor.fetchone(): - raise HTTPException(status_code=404, detail="Meeting not found") - - # 2. 启动异步任务 + return create_api_response(code="404", message="Meeting not found") task_id = async_llm_service.start_summary_generation(meeting_id, request.user_prompt) - - # 3. 将真正的处理函数作为后台任务添加 background_tasks.add_task(async_llm_service._process_task, task_id) - - return { - "message": "Summary generation task has been accepted.", - "task_id": task_id, - "status": "pending", - "meeting_id": meeting_id - } - - except HTTPException: - raise + return create_api_response(code="200", message="Summary generation task has been accepted.", data= { + "task_id": task_id, "status": "pending", "meeting_id": meeting_id + }) except Exception as e: - raise HTTPException(status_code=500, detail=f"Failed to start summary generation: {str(e)}") + return create_api_response(code="500", message=f"Failed to start summary generation: {str(e)}") @router.get("/llm-tasks/{task_id}/status") def get_llm_task_status(task_id: str, current_user: dict = Depends(get_current_user)): - """获取LLM任务状态(包括进度)""" try: status = async_llm_service.get_task_status(task_id) - if status.get('status') == 'not_found': - raise HTTPException(status_code=404, detail="Task not found") - - return status - - except HTTPException: - raise + return create_api_response(code="404", message="Task not found") + return create_api_response(code="200", message="Task status retrieved", data=status) except Exception as e: - raise HTTPException(status_code=500, detail=f"Failed to get task status: {str(e)}") + return create_api_response(code="500", message=f"Failed to get task status: {str(e)}") @router.get("/meetings/{meeting_id}/llm-tasks") def get_meeting_llm_tasks(meeting_id: int, current_user: dict = Depends(get_current_user)): - """获取会议的所有LLM任务历史""" try: - # 检查会议是否存在 with get_db_connection() as connection: cursor = connection.cursor(dictionary=True) cursor.execute("SELECT meeting_id FROM meetings WHERE meeting_id = %s", (meeting_id,)) if not cursor.fetchone(): - raise HTTPException(status_code=404, detail="Meeting not found") - - # 获取任务列表 + return create_api_response(code="404", message="Meeting not found") tasks = async_llm_service.get_meeting_llm_tasks(meeting_id) - - return { - "meeting_id": meeting_id, - "tasks": tasks, - "total": len(tasks) - } - - except HTTPException: - raise + return create_api_response(code="200", message="LLM tasks retrieved successfully", data= { + "tasks": tasks, "total": len(tasks) + }) except Exception as e: - raise HTTPException(status_code=500, detail=f"Failed to get LLM tasks: {str(e)}") - -# @router.get("/meetings/{meeting_id}/latest-llm-task") #此方法被替代 -# def get_latest_llm_task(meeting_id: int, current_user: dict = Depends(get_current_user)): -# """获取会议最新的LLM任务状态""" -# try: -# tasks = async_llm_service.get_meeting_llm_tasks(meeting_id) - -# if not tasks: -# return { -# "meeting_id": meeting_id, -# "task": None, -# "message": "No LLM tasks found for this meeting" -# } - -# # 返回最新的任务 -# latest_task = tasks[0] - -# # 如果任务还在进行中,获取实时状态 -# if latest_task['status'] in ['pending', 'processing']: -# latest_status = async_llm_service.get_task_status(latest_task['task_id']) -# latest_task.update(latest_status) - -# return { -# "meeting_id": meeting_id, -# "task": latest_task -# } - -# except Exception as e: -# raise HTTPException(status_code=500, detail=f"Failed to get latest LLM task: {str(e)}") + return create_api_response(code="500", message=f"Failed to get LLM tasks: {str(e)}") \ No newline at end of file diff --git a/app/api/endpoints/tags.py b/app/api/endpoints/tags.py index fffd419..0964963 100644 --- a/app/api/endpoints/tags.py +++ b/app/api/endpoints/tags.py @@ -1,13 +1,13 @@ - -from fastapi import APIRouter, HTTPException, Depends +from fastapi import APIRouter, Depends from app.core.database import get_db_connection +from app.core.response import create_api_response from app.models.models import Tag from typing import List import mysql.connector router = APIRouter() -@router.get("/tags/", response_model=List[Tag]) +@router.get("/tags/") def get_all_tags(): """_summary_ 获取所有标签 @@ -18,12 +18,12 @@ def get_all_tags(): with connection.cursor(dictionary=True) as cursor: cursor.execute(query) tags = cursor.fetchall() - return tags + return create_api_response(code="200", message="获取标签列表成功", data=tags) except mysql.connector.Error as err: print(f"Error: {err}") - raise HTTPException(status_code=500, detail="Failed to retrieve tags from database.") + return create_api_response(code="500", message="获取标签失败") -@router.post("/tags/", response_model=Tag) +@router.post("/tags/") def create_tag(tag_in: Tag): """_summary_ 创建一个新标签 @@ -36,10 +36,11 @@ def create_tag(tag_in: Tag): cursor.execute(query, (tag_in.name, tag_in.color)) connection.commit() tag_id = cursor.lastrowid - return {"id": tag_id, "name": tag_in.name, "color": tag_in.color} + new_tag = {"id": tag_id, "name": tag_in.name, "color": tag_in.color} + return create_api_response(code="200", message="标签创建成功", data=new_tag) except mysql.connector.IntegrityError: connection.rollback() - raise HTTPException(status_code=400, detail=f"Tag '{tag_in.name}' already exists.") + return create_api_response(code="400", message=f"标签 '{tag_in.name}' 已存在") except mysql.connector.Error as err: print(f"Error: {err}") - raise HTTPException(status_code=500, detail="Failed to create tag in database.") + return create_api_response(code="500", message="创建标签失败") \ No newline at end of file diff --git a/app/api/endpoints/users.py b/app/api/endpoints/users.py index c752036..a6e1fe6 100644 --- a/app/api/endpoints/users.py +++ b/app/api/endpoints/users.py @@ -1,8 +1,8 @@ - -from fastapi import APIRouter, HTTPException, Depends +from fastapi import APIRouter, Depends from app.models.models import UserInfo, PasswordChangeRequest, UserListResponse, CreateUserRequest, UpdateUserRequest, RoleInfo from app.core.database import get_db_connection from app.core.auth import get_current_user +from app.core.response import create_api_response import app.core.config as config_module import hashlib import datetime @@ -22,68 +22,60 @@ def hash_password(password: str) -> str: def get_all_roles(current_user: dict = Depends(get_current_user)): """获取所有角色列表""" if current_user['role_id'] != 1: # 1 is admin - raise HTTPException(status_code=403, detail="仅管理员有权限查看角色列表") + return create_api_response(code="403", message="仅管理员有权限查看角色列表") with get_db_connection() as connection: cursor = connection.cursor(dictionary=True) cursor.execute("SELECT role_id, role_name FROM roles ORDER BY role_id") roles = cursor.fetchall() - return [RoleInfo(**role) for role in roles] + return create_api_response(code="200", message="获取角色列表成功", data=[RoleInfo(**role).dict() for role in roles]) -@router.post("/users", status_code=201) +@router.post("/users") def create_user(request: CreateUserRequest, current_user: dict = Depends(get_current_user)): if current_user['role_id'] != 1: # 1 is admin - raise HTTPException(status_code=403, detail="仅管理员有权限创建用户") + return create_api_response(code="403", message="仅管理员有权限创建用户") - # Validate email format if not validate_email(request.email): - raise HTTPException(status_code=400, detail="邮箱格式不正确") + return create_api_response(code="400", message="邮箱格式不正确") with get_db_connection() as connection: cursor = connection.cursor(dictionary=True) - # Check if username exists cursor.execute("SELECT user_id FROM users WHERE username = %s", (request.username,)) if cursor.fetchone(): - raise HTTPException(status_code=400, detail="用户名已存在") + return create_api_response(code="400", message="用户名已存在") - # Use provided password or default password password = request.password if request.password else config_module.DEFAULT_RESET_PASSWORD hashed_password = hash_password(password) - # Insert new user query = "INSERT INTO users (username, password_hash, caption, email, role_id, created_at) VALUES (%s, %s, %s, %s, %s, %s)" created_at = datetime.datetime.utcnow() cursor.execute(query, (request.username, hashed_password, request.caption, request.email, request.role_id, created_at)) connection.commit() - return {"message": "用户创建成功"} + return create_api_response(code="200", message="用户创建成功") -@router.put("/users/{user_id}", response_model=UserInfo) +@router.put("/users/{user_id}") def update_user(user_id: int, request: UpdateUserRequest, current_user: dict = Depends(get_current_user)): if current_user['role_id'] != 1: # 1 is admin - raise HTTPException(status_code=403, detail="仅管理员有权限修改用户信息") + return create_api_response(code="403", message="仅管理员有权限修改用户信息") - # Validate email format if provided if request.email and not validate_email(request.email): - raise HTTPException(status_code=400, detail="邮箱格式不正确") + return create_api_response(code="400", message="邮箱格式不正确") with get_db_connection() as connection: cursor = connection.cursor(dictionary=True) - # Check if user exists cursor.execute("SELECT user_id, username, caption, email, role_id FROM users WHERE user_id = %s", (user_id,)) existing_user = cursor.fetchone() if not existing_user: - raise HTTPException(status_code=404, detail="用户不存在") + return create_api_response(code="404", message="用户不存在") - # Check if username is being changed and if it already exists if request.username and request.username != existing_user['username']: cursor.execute("SELECT user_id FROM users WHERE username = %s AND user_id != %s", (request.username, user_id)) if cursor.fetchone(): - raise HTTPException(status_code=400, detail="用户名已存在") + return create_api_response(code="400", message="用户名已存在") - # Prepare update data, using existing values if not provided update_data = { 'username': request.username if request.username else existing_user['username'], 'caption': request.caption if request.caption else existing_user['caption'], @@ -91,12 +83,10 @@ def update_user(user_id: int, request: UpdateUserRequest, current_user: dict = D 'role_id': request.role_id if request.role_id is not None else existing_user['role_id'] } - # Update user query = "UPDATE users SET username = %s, caption = %s, email = %s, role_id = %s WHERE user_id = %s" cursor.execute(query, (update_data['username'], update_data['caption'], update_data['email'], update_data['role_id'], user_id)) connection.commit() - # Return updated user info cursor.execute(''' SELECT u.user_id, u.username, u.caption, u.email, u.created_at, u.role_id, r.role_name FROM users u @@ -105,7 +95,7 @@ def update_user(user_id: int, request: UpdateUserRequest, current_user: dict = D ''', (user_id,)) updated_user = cursor.fetchone() - return UserInfo( + user_info = UserInfo( user_id=updated_user['user_id'], username=updated_user['username'], caption=updated_user['caption'], @@ -113,62 +103,56 @@ def update_user(user_id: int, request: UpdateUserRequest, current_user: dict = D created_at=updated_user['created_at'], role_id=updated_user['role_id'], role_name=updated_user['role_name'], - meetings_created=0, # This is not accurate, but it is not displayed in the list + meetings_created=0, meetings_attended=0 ) + return create_api_response(code="200", message="用户信息更新成功", data=user_info.dict()) @router.delete("/users/{user_id}") def delete_user(user_id: int, current_user: dict = Depends(get_current_user)): if current_user['role_id'] != 1: # 1 is admin - raise HTTPException(status_code=403, detail="仅管理员有权限删除用户") + return create_api_response(code="403", message="仅管理员有权限删除用户") with get_db_connection() as connection: cursor = connection.cursor(dictionary=True) - # Check if user exists cursor.execute("SELECT user_id FROM users WHERE user_id = %s", (user_id,)) if not cursor.fetchone(): - raise HTTPException(status_code=404, detail="用户不存在") + return create_api_response(code="404", message="用户不存在") - # Delete user cursor.execute("DELETE FROM users WHERE user_id = %s", (user_id,)) connection.commit() - return {"message": "用户删除成功"} + return create_api_response(code="200", message="用户删除成功") @router.post("/users/{user_id}/reset-password") def reset_password(user_id: int, current_user: dict = Depends(get_current_user)): if current_user['role_id'] != 1: # 1 is admin - raise HTTPException(status_code=403, detail="仅管理员有权限重置密码") + return create_api_response(code="403", message="仅管理员有权限重置密码") with get_db_connection() as connection: cursor = connection.cursor(dictionary=True) - # Check if user exists cursor.execute("SELECT user_id FROM users WHERE user_id = %s", (user_id,)) if not cursor.fetchone(): - raise HTTPException(status_code=404, detail="用户不存在") + return create_api_response(code="404", message="用户不存在") - # Hash password hashed_password = hash_password(config_module.DEFAULT_RESET_PASSWORD) - # Update user password query = "UPDATE users SET password_hash = %s WHERE user_id = %s" cursor.execute(query, (hashed_password, user_id)) connection.commit() - return {"message": f"用户 {user_id} 的密码已重置"} + return create_api_response(code="200", message=f"用户 {user_id} 的密码已重置") -@router.get("/users", response_model=UserListResponse) +@router.get("/users") def get_all_users(page: int = 1, size: int = 10, current_user: dict = Depends(get_current_user)): with get_db_connection() as connection: cursor = connection.cursor(dictionary=True) - # Get total count cursor.execute("SELECT COUNT(*) as total FROM users") total = cursor.fetchone()['total'] - # Get paginated users with role names offset = (page - 1) * size query = ''' SELECT @@ -186,9 +170,10 @@ def get_all_users(page: int = 1, size: int = 10, current_user: dict = Depends(ge user_list = [UserInfo(**user) for user in users] - return UserListResponse(users=user_list, total=total) + response_data = UserListResponse(users=user_list, total=total) + return create_api_response(code="200", message="获取用户列表成功", data=response_data.dict()) -@router.get("/users/{user_id}", response_model=UserInfo) +@router.get("/users/{user_id}") def get_user_info(user_id: int, current_user: dict = Depends(get_current_user)): with get_db_connection() as connection: cursor = connection.cursor(dictionary=True) @@ -203,7 +188,7 @@ def get_user_info(user_id: int, current_user: dict = Depends(get_current_user)): user = cursor.fetchone() if not user: - raise HTTPException(status_code=404, detail="用户不存在") + return create_api_response(code="404", message="用户不存在") created_query = "SELECT COUNT(*) as count FROM meetings WHERE user_id = %s" cursor.execute(created_query, (user_id,)) @@ -213,7 +198,7 @@ def get_user_info(user_id: int, current_user: dict = Depends(get_current_user)): cursor.execute(attended_query, (user_id,)) meetings_attended = cursor.fetchone()['count'] - return UserInfo( + user_info = UserInfo( user_id=user['user_id'], username=user['username'], caption=user['caption'], @@ -224,11 +209,12 @@ def get_user_info(user_id: int, current_user: dict = Depends(get_current_user)): meetings_created=meetings_created, meetings_attended=meetings_attended ) + return create_api_response(code="200", message="获取用户信息成功", data=user_info.dict()) @router.put("/users/{user_id}/password") def update_password(user_id: int, request: PasswordChangeRequest, current_user: dict = Depends(get_current_user)): if user_id != current_user['user_id'] and current_user['role_id'] != 1: - raise HTTPException(status_code=403, detail="没有权限修改其他用户的密码") + return create_api_response(code="403", message="没有权限修改其他用户的密码") with get_db_connection() as connection: cursor = connection.cursor(dictionary=True) @@ -237,15 +223,14 @@ def update_password(user_id: int, request: PasswordChangeRequest, current_user: user = cursor.fetchone() if not user: - raise HTTPException(status_code=404, detail="用户不存在") + return create_api_response(code="404", message="用户不存在") - # If not admin, verify old password if current_user['role_id'] != 1: if user['password_hash'] != hash_password(request.old_password): - raise HTTPException(status_code=400, detail="旧密码错误") + return create_api_response(code="400", message="旧密码错误") new_password_hash = hash_password(request.new_password) cursor.execute("UPDATE users SET password_hash = %s WHERE user_id = %s", (new_password_hash, user_id)) connection.commit() - return {"message": "密码修改成功"} + return create_api_response(code="200", message="密码修改成功") \ No newline at end of file diff --git a/app/core/config.py b/app/core/config.py index ffff929..1a9f5fb 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -23,7 +23,7 @@ DATABASE_CONFIG = { 'host': os.getenv('DB_HOST', '10.100.51.161'), 'user': os.getenv('DB_USER', 'root'), 'password': os.getenv('DB_PASSWORD', 'sagacity'), - 'database': os.getenv('DB_NAME', 'imeeting'), + 'database': os.getenv('DB_NAME', 'imeeting_dev'), 'port': int(os.getenv('DB_PORT', '3306')), 'charset': 'utf8mb4' } @@ -42,7 +42,7 @@ QINIU_DOMAIN = os.getenv('QINIU_DOMAIN', 't0vogyxkz.hn-bkt.clouddn.com') # 应用配置 APP_CONFIG = { - 'base_url': os.getenv('BASE_URL', 'http://imeeting.unisspace.com') + 'base_url': os.getenv('BASE_URL', 'http://dev.imeeting.unisspace.com') } # Redis配置 diff --git a/app/core/response.py b/app/core/response.py new file mode 100644 index 0000000..3561d4d --- /dev/null +++ b/app/core/response.py @@ -0,0 +1,14 @@ +from typing import Union +from fastapi.responses import JSONResponse +from fastapi.encoders import jsonable_encoder + +def create_api_response(code: str, message: str, data: Union[dict, list, None] = None) -> JSONResponse: + """Creates a standardized API JSON response.""" + return JSONResponse( + status_code=200, + content={ + "code": str(code), + "message": message, + "data": jsonable_encoder(data) if data is not None else {}, + }, + ) \ No newline at end of file diff --git a/config/.DS_Store b/config/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..e129a5b3ddfeb17d0baa6ac9257e07afe601e20b GIT binary patch literal 6148 zcmeHKyH3ME5S)V)k!T_+%KHKge_%zSLr@?(5D;RJ6L6x$F6jKT{0FnQ3&O$$G*N_h zt-ZP1$34dKoew}5H?vb<1Yk%N3{vS4G55OmELbq4J_@|z4v$!(>gi|~O)~dGZ16^{ z@qPYJDDjMA%<18JR`v9}+0<*spWvFd0&A8T{Uu(g4YJ$!%Ntr&^ekyF$V8oDO7c?h zsTo(X*9=oU(7T|m!i;U@OvlXrFsBQf)34UBi!Z?Eteg z0cYTg0oflSRWLVf71P$iB3%K91DcbtE_VsZiH5mht4I$eXev=tgIzI#rqdoRE;noy zH60<1%zkX-&&x|lqthN!I6|)Ir8D3RbQ##!=19)}Gk!9?m;7#ux10fI;GZ!N29vAF zn2WNr^~dt$tc}!rs))q3QlYTk`3Yb{_K~CHRDYB@<8s4RQF;-3N+